Allow naming the model artist explicitly, and changing it later

This commit is contained in:
hfcRed
2026-09-12 17:07:59 -04:00
parent 01a783d70b
commit d4d76daf16
40 changed files with 173 additions and 29 deletions

View File

@@ -208,6 +208,7 @@ export interface PendingTrophy {
acceptedAt: number | null;
targetTrophyId: number | null;
managerId: number | null;
creatorId: number | null;
}
export interface PendingTrophyApproval {

View File

@@ -52,6 +52,34 @@ describe("trophy approvals", () => {
expect(await trophyCount()).toBe(1);
});
test("the named creator becomes the trophy's creator", async () => {
const artist = await UserFactory.create();
const submitter = await UserFactory.create();
const organization = await TournamentOrganizationFactory.create({
ownerId: submitter.id,
});
const pending = await TrophyFactory.createPending({
organizationId: organization.id,
submitterUserId: submitter.id,
creatorId: artist.id,
});
let accepted = null;
for (const userId of reviewerIds.slice(0, TROPHY_APPROVALS_REQUIRED)) {
accepted = await TrophyRepository.addApproval({
pendingTrophyId: pending.id,
userId,
});
}
const trophy = await db
.selectFrom("Trophy")
.select(["creatorId", "managerId"])
.where("id", "=", accepted!.id)
.executeTakeFirstOrThrow();
expect(trophy).toEqual({ creatorId: artist.id, managerId: submitter.id });
});
test("ignores repeated approvals from the same user", async () => {
await TrophyRepository.addApproval({
pendingTrophyId,

View File

@@ -494,7 +494,7 @@ export async function existsByName(args: {
export async function findManagedBy(userId: number) {
return db
.selectFrom("Trophy")
.select(["id", "name", "model", "organizationId", "managerId"])
.select(["id", "name", "model", "organizationId", "managerId", "creatorId"])
.where("managerId", "=", userId)
.execute();
}
@@ -502,7 +502,7 @@ export async function findManagedBy(userId: number) {
export async function findAllForEditing() {
return db
.selectFrom("Trophy")
.select(["id", "name", "model", "organizationId", "managerId"])
.select(["id", "name", "model", "organizationId", "managerId", "creatorId"])
.where("code", "is", null)
.execute();
}
@@ -616,6 +616,7 @@ export async function createPending(args: {
submitterUserId: number;
targetTrophyId?: number;
managerId?: number;
creatorId?: number;
}) {
return db
.insertInto("PendingTrophy")
@@ -631,6 +632,7 @@ export async function createPending(args: {
declinedByUserId: null,
targetTrophyId: args.targetTrophyId ?? null,
managerId: args.managerId ?? null,
creatorId: args.creatorId ?? null,
})
.returning("id")
.executeTakeFirstOrThrow();
@@ -660,6 +662,7 @@ const withTarget = (eb: ExpressionBuilder<DB, "PendingTrophy">) => {
eb
.selectFrom("Trophy")
.leftJoin("User", "User.id", "Trophy.managerId")
.leftJoin("User as Creator", "Creator.id", "Trophy.creatorId")
.leftJoin(
"TournamentOrganization",
"TournamentOrganization.id",
@@ -671,7 +674,9 @@ const withTarget = (eb: ExpressionBuilder<DB, "PendingTrophy">) => {
"Trophy.model",
"Trophy.organizationId",
"Trophy.managerId",
"Trophy.creatorId",
"User.username as managerUsername",
"Creator.username as creatorUsername",
"TournamentOrganization.name as organizationName",
"TournamentOrganization.slug as organizationSlug",
])
@@ -688,6 +693,15 @@ const withTargetManager = (eb: ExpressionBuilder<DB, "PendingTrophy">) => {
).as("manager");
};
const withPendingCreator = (eb: ExpressionBuilder<DB, "PendingTrophy">) => {
return jsonObjectFrom(
eb
.selectFrom("User")
.select(["User.id", "User.username", "User.discordId"])
.whereRef("User.id", "=", "PendingTrophy.creatorId"),
).as("creator");
};
function pendingBaseQuery() {
return db
.selectFrom("PendingTrophy")
@@ -720,6 +734,7 @@ function pendingBaseQuery() {
"PendingTrophy.acceptedAt",
"PendingTrophy.targetTrophyId",
"PendingTrophy.managerId",
"PendingTrophy.creatorId",
"Submitter.username as submitterUsername",
"Submitter.discordId as submitterDiscordId",
"Decliner.username as declinedByUsername",
@@ -728,6 +743,7 @@ function pendingBaseQuery() {
withApprovals(eb),
withTarget(eb),
withTargetManager(eb),
withPendingCreator(eb),
]);
}
@@ -842,6 +858,7 @@ export async function addApproval(args: {
"submitterUserId",
"targetTrophyId",
"managerId",
"creatorId",
])
.where("id", "=", args.pendingTrophyId)
.where("declinedAt", "is", null)
@@ -864,6 +881,9 @@ export async function addApproval(args: {
model: pending.model,
organizationId: pending.organizationId,
managerId: pending.managerId ?? pending.submitterUserId,
...(pending.creatorId !== null
? { creatorId: pending.creatorId }
: {}),
})
.where("id", "=", pending.targetTrophyId)
.execute();
@@ -876,7 +896,7 @@ export async function addApproval(args: {
name: pending.name,
model: pending.model,
organizationId: pending.organizationId,
creatorId: pending.submitterUserId,
creatorId: pending.creatorId ?? pending.submitterUserId,
managerId: pending.managerId ?? pending.submitterUserId,
})
.returning("id")

View File

@@ -67,6 +67,7 @@ export const action: ActionFunction = async ({ request }) => {
submitterUserId: user.id,
targetTrophyId: data.targetTrophyId,
managerId: data.managerId,
creatorId: data.creatorId ?? undefined,
});
await notifyReviewersOfSubmission({
@@ -90,6 +91,7 @@ export const action: ActionFunction = async ({ request }) => {
description: data.description ?? "",
organizationId: data.organizationId,
submitterUserId: user.id,
creatorId: data.creatorId ?? user.id,
});
await notifyReviewersOfSubmission({

View File

@@ -64,5 +64,32 @@ describe("trophy submissions before release", () => {
await TrophyRepository.pendingBySubmitter(REGULAR_USER_TEST_ID);
expect(pending.length).toBe(1);
expect(pending[0].name).toBe("Regular Trophy");
expect(pending[0].creatorId).toBe(REGULAR_USER_TEST_ID);
});
test("a submission can name someone else as the creator", async () => {
const organization = await TournamentOrganizationFactory.create({
ownerId: ADMIN_ID,
});
const artist = await UserFactory.create();
const result = await submitAction(
{
_action: "CREATE",
name: "Commissioned Trophy",
model: decompressFromBase64(TrophyFactory.MODELS[0]) ?? "",
organizationId: organization.id,
creatorId: artist.id,
description: null,
},
{ user: "regular" },
);
expect(result).toBe(null);
const pending =
await TrophyRepository.pendingBySubmitter(REGULAR_USER_TEST_ID);
expect(pending[0].creatorId).toBe(artist.id);
expect(pending[0].creator?.id).toBe(artist.id);
});
});

View File

@@ -197,10 +197,14 @@ function TrophyTermsGate({ children }: { children: React.ReactNode }) {
}
function NewTrophyForm() {
const { t } = useTranslation(["trophies"]);
const { t } = useTranslation(["trophies", "forms"]);
const data = useLoaderData<typeof loader>();
return (
<SendouForm schema={createTrophyFormSchema}>
<SendouForm
schema={createTrophyFormSchema}
defaultValues={{ creatorId: data.currentUserId }}
>
{({ FormField }) => (
<>
<FormField name="name" />
@@ -213,6 +217,16 @@ function NewTrophyForm() {
/>
)}
</FormField>
<FormField name="creatorId">
{({ error, value, onChange }: CustomFieldRenderProps) => (
<UserField
label={t("forms:labels.trophyCreator")}
error={error}
value={value as number | null}
onChange={onChange}
/>
)}
</FormField>
<FormField name="model">
{({ name, error, value, onChange }: CustomFieldRenderProps) => (
<ModelField
@@ -224,9 +238,6 @@ function NewTrophyForm() {
)}
</FormField>
<FormField name="description" />
<FormMessage type="info">
{t("trophies:new.form.creatorNotice")}
</FormMessage>
</>
)}
</SendouForm>
@@ -273,6 +284,7 @@ function UpdateTrophyForm({
}: {
trophy: NewTrophyLoaderData["editableTrophies"][number];
}) {
const { t } = useTranslation(["forms"]);
const decompressedModel = decompressTrophyModel(trophy.model) ?? "";
return (
@@ -284,6 +296,7 @@ function UpdateTrophyForm({
model: decompressedModel,
organizationId: trophy.organizationId,
managerId: trophy.managerId,
creatorId: trophy.creatorId,
description: "",
}}
>
@@ -301,7 +314,18 @@ function UpdateTrophyForm({
</FormField>
<FormField name="managerId">
{({ error, value, onChange }: CustomFieldRenderProps) => (
<ManagerField
<UserField
label={t("forms:labels.trophyManager")}
error={error}
value={value as number | null}
onChange={onChange}
/>
)}
</FormField>
<FormField name="creatorId">
{({ error, value, onChange }: CustomFieldRenderProps) => (
<UserField
label={t("forms:labels.trophyCreator")}
error={error}
value={value as number | null}
onChange={onChange}
@@ -325,20 +349,20 @@ function UpdateTrophyForm({
);
}
function ManagerField({
function UserField({
label,
error,
value,
onChange,
}: {
label: string;
error?: string;
value: number | null;
onChange: (value: number | null) => void;
}) {
const { t } = useTranslation(["forms"]);
return (
<div>
<Label required>{t("forms:labels.trophyManager")}</Label>
<Label required>{label}</Label>
<UserSearch
initialUserId={value ?? undefined}
onChange={(user) => onChange(user?.id ?? null)}
@@ -718,6 +742,23 @@ function TrophyListRow({
) : (
pending.submitterUsername
)}
{pending.creator &&
pending.creator.id !==
(pending.manager?.id ?? pending.submitterUserId) ? (
<>
{" • "}
<Trans
t={t}
i18nKey="trophies:details.createdBy"
values={{ name: pending.creator.username }}
>
Created by
<Link to={userPage({ discordId: pending.creator.discordId })}>
{pending.creator.username}
</Link>
</Trans>
</>
) : null}
{pending.organizationName ? (
<>
{" • "}
@@ -963,6 +1004,13 @@ function PendingTrophyDiff({
newValue: newManagerName,
changed: target.managerId !== newManagerId,
},
{
label: t("forms:labels.trophyCreator"),
oldValue: target.creatorUsername ?? "—",
newValue: pending.creator?.username ?? target.creatorUsername ?? "—",
changed:
pending.creatorId !== null && target.creatorId !== pending.creatorId,
},
{
label: t("forms:labels.trophyModel"),
oldValue: "-----",

View File

@@ -56,6 +56,7 @@ export const createTrophyFormSchema = v.object({
}),
model: trophyModelField(),
organizationId: customField({ initialValue: null }, id),
creatorId: customField({ initialValue: null }, v.nullish(id)),
description: textAreaOptional({
label: "labels.trophyInformation",
maxLength: TROPHY_DESCRIPTION_MAX_LENGTH,
@@ -73,6 +74,7 @@ export const updateTrophyFormSchema = v.object({
model: trophyModelField(),
organizationId: customField({ initialValue: null }, id),
managerId: customField({ initialValue: null }, id),
creatorId: customField({ initialValue: null }, v.nullish(id)),
description: textAreaOptional({
label: "labels.trophyInformation",
maxLength: TROPHY_DESCRIPTION_MAX_LENGTH,

View File

@@ -459,6 +459,7 @@
"labels.trophyModel": "",
"labels.trophyOrganization": "",
"labels.trophyManager": "",
"labels.trophyCreator": "",
"labels.trophyInformation": "",
"labels.profileFavoriteTrophies": "",
"labels.profileHiddenTrophies": "",

View File

@@ -13,7 +13,6 @@
"display.viewTrophyPage": "",
"special.supporter.description": "",
"special.xp.description": "",
"new.form.creatorNotice": "",
"new.form.limitReached": "",
"new.form.preview.light": "",
"new.form.preview.dark": "",

View File

@@ -459,6 +459,7 @@
"labels.trophyModel": "",
"labels.trophyOrganization": "",
"labels.trophyManager": "",
"labels.trophyCreator": "",
"labels.trophyInformation": "",
"labels.profileFavoriteTrophies": "",
"labels.profileHiddenTrophies": "",

View File

@@ -13,7 +13,6 @@
"display.viewTrophyPage": "",
"special.supporter.description": "",
"special.xp.description": "",
"new.form.creatorNotice": "",
"new.form.limitReached": "",
"new.form.preview.light": "",
"new.form.preview.dark": "",

View File

@@ -459,6 +459,7 @@
"labels.trophyModel": "3D model state",
"labels.trophyOrganization": "Organization",
"labels.trophyManager": "Manager",
"labels.trophyCreator": "3D model creator",
"labels.trophyInformation": "Additional information",
"labels.profileFavoriteTrophies": "Favorite trophies",
"labels.profileHiddenTrophies": "Hidden trophies",

View File

@@ -13,7 +13,6 @@
"display.viewTrophyPage": "View trophy page",
"special.supporter.description": "Awarded for supporting sendou.ink",
"special.xp.description": "Awarded for reaching {{value}} X Power",
"new.form.creatorNotice": "The user who uploads a trophy is registered as its creator. This can not be changed. If you commissioned a trophy from someone and they want to be listed as its creator, they have to upload it themselves.",
"new.form.limitReached": "You have reached the limit of {{limit}} pending trophies. Delete or wait for an existing submission to be reviewed before submitting another.",
"new.form.preview.light": "Light mode",
"new.form.preview.dark": "Dark mode",

View File

@@ -459,6 +459,7 @@
"labels.trophyModel": "Estado del modelo 3D",
"labels.trophyOrganization": "Organización",
"labels.trophyManager": "Mánager",
"labels.trophyCreator": "",
"labels.trophyInformation": "Información adicional",
"labels.profileFavoriteTrophies": "Trofeos favoritos",
"labels.profileHiddenTrophies": "Trofeos ocultos",

View File

@@ -13,7 +13,6 @@
"display.viewTrophyPage": "Ver página del trofeo",
"special.supporter.description": "Otorgado por apoyar a sendou.ink",
"special.xp.description": "Otorgado por alcanzar {{value}} de Energía X",
"new.form.creatorNotice": "El usuario que sube un trofeo queda registrado como su creador. Esto no se puede cambiar. Si le encargaste un trofeo a alguien y quiere aparecer como su creador, tiene que subirlo esa persona.",
"new.form.limitReached": "Has alcanzado el límite de {{limit}} trofeos pendientes. Borra o espera a que se revise un envío existente antes de enviar otro.",
"new.form.preview.light": "Modo claro",
"new.form.preview.dark": "Modo oscuro",

View File

@@ -459,6 +459,7 @@
"labels.trophyModel": "Estado del modelo 3D",
"labels.trophyOrganization": "Organización",
"labels.trophyManager": "Mánager",
"labels.trophyCreator": "",
"labels.trophyInformation": "Información adicional",
"labels.profileFavoriteTrophies": "Trofeos favoritos",
"labels.profileHiddenTrophies": "Trofeos ocultos",

View File

@@ -13,7 +13,6 @@
"display.viewTrophyPage": "Ver página del trofeo",
"special.supporter.description": "Otorgado por apoyar a sendou.ink",
"special.xp.description": "Otorgado por alcanzar {{value}} de Energía X",
"new.form.creatorNotice": "El usuario que sube un trofeo queda registrado como su creador. Esto no se puede cambiar. Si le encargaste un trofeo a alguien y quiere aparecer como su creador, tiene que subirlo esa persona.",
"new.form.limitReached": "Has alcanzado el límite de {{limit}} trofeos pendientes. Borra o espera a que se revise un envío existente antes de enviar otro.",
"new.form.preview.light": "Modo claro",
"new.form.preview.dark": "Modo oscuro",

View File

@@ -459,6 +459,7 @@
"labels.trophyModel": "",
"labels.trophyOrganization": "",
"labels.trophyManager": "",
"labels.trophyCreator": "",
"labels.trophyInformation": "",
"labels.profileFavoriteTrophies": "",
"labels.profileHiddenTrophies": "",

View File

@@ -13,7 +13,6 @@
"display.viewTrophyPage": "",
"special.supporter.description": "",
"special.xp.description": "",
"new.form.creatorNotice": "",
"new.form.limitReached": "",
"new.form.preview.light": "",
"new.form.preview.dark": "",

View File

@@ -459,6 +459,7 @@
"labels.trophyModel": "",
"labels.trophyOrganization": "",
"labels.trophyManager": "",
"labels.trophyCreator": "",
"labels.trophyInformation": "",
"labels.profileFavoriteTrophies": "",
"labels.profileHiddenTrophies": "",

View File

@@ -13,7 +13,6 @@
"display.viewTrophyPage": "",
"special.supporter.description": "",
"special.xp.description": "",
"new.form.creatorNotice": "",
"new.form.limitReached": "",
"new.form.preview.light": "",
"new.form.preview.dark": "",

View File

@@ -459,6 +459,7 @@
"labels.trophyModel": "",
"labels.trophyOrganization": "",
"labels.trophyManager": "",
"labels.trophyCreator": "",
"labels.trophyInformation": "",
"labels.profileFavoriteTrophies": "",
"labels.profileHiddenTrophies": "",

View File

@@ -13,7 +13,6 @@
"display.viewTrophyPage": "",
"special.supporter.description": "",
"special.xp.description": "",
"new.form.creatorNotice": "",
"new.form.limitReached": "",
"new.form.preview.light": "",
"new.form.preview.dark": "",

View File

@@ -459,6 +459,7 @@
"labels.trophyModel": "",
"labels.trophyOrganization": "",
"labels.trophyManager": "",
"labels.trophyCreator": "",
"labels.trophyInformation": "",
"labels.profileFavoriteTrophies": "",
"labels.profileHiddenTrophies": "",

View File

@@ -13,7 +13,6 @@
"display.viewTrophyPage": "",
"special.supporter.description": "",
"special.xp.description": "",
"new.form.creatorNotice": "",
"new.form.limitReached": "",
"new.form.preview.light": "",
"new.form.preview.dark": "",

View File

@@ -459,6 +459,7 @@
"labels.trophyModel": "",
"labels.trophyOrganization": "",
"labels.trophyManager": "",
"labels.trophyCreator": "",
"labels.trophyInformation": "",
"labels.profileFavoriteTrophies": "",
"labels.profileHiddenTrophies": "",

View File

@@ -13,7 +13,6 @@
"display.viewTrophyPage": "",
"special.supporter.description": "",
"special.xp.description": "",
"new.form.creatorNotice": "",
"new.form.limitReached": "",
"new.form.preview.light": "",
"new.form.preview.dark": "",

View File

@@ -459,6 +459,7 @@
"labels.trophyModel": "",
"labels.trophyOrganization": "",
"labels.trophyManager": "",
"labels.trophyCreator": "",
"labels.trophyInformation": "",
"labels.profileFavoriteTrophies": "",
"labels.profileHiddenTrophies": "",

View File

@@ -13,7 +13,6 @@
"display.viewTrophyPage": "",
"special.supporter.description": "",
"special.xp.description": "",
"new.form.creatorNotice": "",
"new.form.limitReached": "",
"new.form.preview.light": "",
"new.form.preview.dark": "",

View File

@@ -459,6 +459,7 @@
"labels.trophyModel": "",
"labels.trophyOrganization": "",
"labels.trophyManager": "",
"labels.trophyCreator": "",
"labels.trophyInformation": "",
"labels.profileFavoriteTrophies": "",
"labels.profileHiddenTrophies": "",

View File

@@ -13,7 +13,6 @@
"display.viewTrophyPage": "",
"special.supporter.description": "",
"special.xp.description": "",
"new.form.creatorNotice": "",
"new.form.limitReached": "",
"new.form.preview.light": "",
"new.form.preview.dark": "",

View File

@@ -459,6 +459,7 @@
"labels.trophyModel": "",
"labels.trophyOrganization": "",
"labels.trophyManager": "",
"labels.trophyCreator": "",
"labels.trophyInformation": "",
"labels.profileFavoriteTrophies": "",
"labels.profileHiddenTrophies": "",

View File

@@ -13,7 +13,6 @@
"display.viewTrophyPage": "",
"special.supporter.description": "",
"special.xp.description": "",
"new.form.creatorNotice": "",
"new.form.limitReached": "",
"new.form.preview.light": "",
"new.form.preview.dark": "",

View File

@@ -459,6 +459,7 @@
"labels.trophyModel": "",
"labels.trophyOrganization": "",
"labels.trophyManager": "",
"labels.trophyCreator": "",
"labels.trophyInformation": "",
"labels.profileFavoriteTrophies": "",
"labels.profileHiddenTrophies": "",

View File

@@ -13,7 +13,6 @@
"display.viewTrophyPage": "",
"special.supporter.description": "",
"special.xp.description": "",
"new.form.creatorNotice": "",
"new.form.limitReached": "",
"new.form.preview.light": "",
"new.form.preview.dark": "",

View File

@@ -459,6 +459,7 @@
"labels.trophyModel": "",
"labels.trophyOrganization": "",
"labels.trophyManager": "",
"labels.trophyCreator": "",
"labels.trophyInformation": "",
"labels.profileFavoriteTrophies": "",
"labels.profileHiddenTrophies": "",

View File

@@ -13,7 +13,6 @@
"display.viewTrophyPage": "",
"special.supporter.description": "",
"special.xp.description": "",
"new.form.creatorNotice": "",
"new.form.limitReached": "",
"new.form.preview.light": "",
"new.form.preview.dark": "",

View File

@@ -459,6 +459,7 @@
"labels.trophyModel": "",
"labels.trophyOrganization": "",
"labels.trophyManager": "",
"labels.trophyCreator": "",
"labels.trophyInformation": "",
"labels.profileFavoriteTrophies": "",
"labels.profileHiddenTrophies": "",

View File

@@ -13,7 +13,6 @@
"display.viewTrophyPage": "",
"special.supporter.description": "",
"special.xp.description": "",
"new.form.creatorNotice": "",
"new.form.limitReached": "",
"new.form.preview.light": "",
"new.form.preview.dark": "",

View File

@@ -0,0 +1,16 @@
import type { Kysely } from "kysely";
/**
* A submission names the model's creator. Before, the submitter always became
* the creator, so a commissioned trophy had to be uploaded by its artist.
*/
export async function up(db: Kysely<any>): Promise<void> {
await db.transaction().execute(async (trx) => {
await trx.schema
.alterTable("PendingTrophy")
.addColumn("creatorId", "integer", (col) =>
col.references("User.id").onDelete("set null"),
)
.execute();
});
}