From dc7ebd6873d9aaea059cacf45bf95d0026b780ee Mon Sep 17 00:00:00 2001 From: Dominic Ferrando Date: Sat, 19 Sep 2026 17:57:52 -0400 Subject: [PATCH] Add a verification interface for review --- .../api/src/scripts/seed-test-verification.ts | 95 ++++ apps/api/src/server.ts | 8 + apps/api/src/services/verifications.ts | 51 +- apps/portal/src/routes/(app)/+layout.svelte | 5 + .../(app)/verification/requests/+page.svelte | 171 +++++++ .../verification/requests/[id]/+page.svelte | 446 ++++++++++++++++++ 6 files changed, 772 insertions(+), 4 deletions(-) create mode 100644 apps/api/src/scripts/seed-test-verification.ts create mode 100644 apps/portal/src/routes/(app)/verification/requests/+page.svelte create mode 100644 apps/portal/src/routes/(app)/verification/requests/[id]/+page.svelte diff --git a/apps/api/src/scripts/seed-test-verification.ts b/apps/api/src/scripts/seed-test-verification.ts new file mode 100644 index 0000000..ff94e22 --- /dev/null +++ b/apps/api/src/scripts/seed-test-verification.ts @@ -0,0 +1,95 @@ +import { Activity, Gender, inToCm, lbToKg, minToMs, secToMs } from "@blade-and-brawn/domain"; +import { db } from "../database/db"; +import { env, log } from "../util"; +import type { AccountRole } from "../services/accounts"; +import { VerificationsService } from "../services/verifications"; + +const TEST_DISCORD_ID = "test-verification-seed"; +const TEST_ASSESSMENT_NAME = "Test Verification Player"; + +async function seedTestAccount(): Promise { + const existing = await db.selectFrom("accounts") + .select("id") + .where("discord_id", "=", TEST_DISCORD_ID) + .executeTakeFirst(); + if (existing) { + log.info({ id: existing.id }, "test account already seeded, reusing"); + return existing.id; + } + + const account = await db.insertInto("accounts") + .values({ + discord_id: TEST_DISCORD_ID, + name: "Test Player", + role: "user" satisfies AccountRole, + }) + .returning("id") + .executeTakeFirstOrThrow(); + log.info({ id: account.id }, "seeded test account"); + return account.id; +} + +async function seedTestAssessment(accountId: string): Promise { + const existing = await db.selectFrom("assessments") + .select("id") + .where("account_id", "=", accountId) + .where("name", "=", TEST_ASSESSMENT_NAME) + .executeTakeFirst(); + if (existing) { + log.info({ id: existing.id }, "test assessment already seeded, reusing"); + return existing.id; + } + + const result = await db.insertInto("assessments") + .values({ + account_id: accountId, + name: TEST_ASSESSMENT_NAME, + gender: Gender.Male, + age: 22, + weight: lbToKg(180), + perf_back_squat: lbToKg(225), + perf_deadlift: lbToKg(315), + perf_bench_press: lbToKg(185), + perf_broad_jump: inToCm(96), + perf_run: minToMs(7) + secToMs(30), + perf_cone_drill: secToMs(7), + }) + .returning("id") + .executeTakeFirstOrThrow(); + log.info({ id: result.id }, "seeded test assessment"); + return result.id; +} + +(async () => { + const answer = prompt(`Seed a test verification request in the database (${env.DATABASE_URL})? (y/N)`); + if (answer?.trim().toLowerCase() !== "y") { + log.info("Aborted"); + await db.destroy(); + return; + } + + try { + const accountId = await seedTestAccount(); + const assessmentId = await seedTestAssessment(accountId); + + const verifications = new VerificationsService(); + const verification = await verifications.request(assessmentId, accountId, { + [Activity.BackSquat]: "https://example.com/videos/back-squat.mp4", + [Activity.Deadlift]: "https://example.com/videos/deadlift.mp4", + [Activity.BenchPress]: "https://example.com/videos/bench-press.mp4", + [Activity.Run]: "https://example.com/videos/run.mp4", + [Activity.BroadJump]: "https://example.com/videos/broad-jump.mp4", + [Activity.ConeDrill]: "https://example.com/videos/cone-drill.mp4", + }); + if (!verification) throw new Error("Failed to seed verification request — assessment ownership check failed"); + + log.info({ id: verification.id, assessmentId, accountId }, "seeded test verification request"); + } + catch (err) { + log.error({ err }, "seed failed"); + process.exit(1); + } + finally { + await db.destroy(); + } +})(); diff --git a/apps/api/src/server.ts b/apps/api/src/server.ts index 3afeca5..98df40b 100644 --- a/apps/api/src/server.ts +++ b/apps/api/src/server.ts @@ -555,6 +555,13 @@ export const app = new Elysia() offset: t.Optional(t.Numeric()), }) }) + .get("/:id", async ({ params: { id } }) => { + const verification = await s.Verifications.get(id); + if (!verification) throw new NotFoundError("Verification not found"); + return verification; + }, { + params: t.Object({ id: t.String() }) + }) .post("/:id/request-action", async ({ params: { id }, body: { reviewerNotes, activityVerifications } }) => { const updated = await s.Verifications.requestAction(id, reviewerNotes ?? null, activityVerifications); if (!updated) throw new NotFoundError("Verification not found"); @@ -684,3 +691,4 @@ app.listen(3000, async () => { export type API = typeof app export { type EventStatus } from "./services/events"; +export { type VerificationStatus } from "./services/verifications"; diff --git a/apps/api/src/services/verifications.ts b/apps/api/src/services/verifications.ts index 9849d03..a85206b 100644 --- a/apps/api/src/services/verifications.ts +++ b/apps/api/src/services/verifications.ts @@ -59,17 +59,60 @@ export class VerificationsService { .executeTakeFirstOrThrow(); } + // Joined with the assessment being verified, since reviewing a request + // requires seeing the player's claimed performance alongside the videos. + private static baseQuery() { + return db.selectFrom("verifications") + .innerJoin("assessments", "assessments.id", "verifications.assessment_id") + .select([ + "verifications.id", + "verifications.assessment_id", + "verifications.status", + "verifications.created_at", + "verifications.completed_at", + "verifications.completed_by", + "verifications.reviewer_notes", + "verifications.url_back_squat", + "verifications.url_deadlift", + "verifications.url_bench_press", + "verifications.url_broad_jump", + "verifications.url_run", + "verifications.url_cone_drill", + "verifications.verf_back_squat", + "verifications.verf_deadlift", + "verifications.verf_bench_press", + "verifications.verf_broad_jump", + "verifications.verf_run", + "verifications.verf_cone_drill", + "assessments.name", + "assessments.gender", + "assessments.age", + "assessments.weight", + "assessments.perf_back_squat", + "assessments.perf_deadlift", + "assessments.perf_bench_press", + "assessments.perf_broad_jump", + "assessments.perf_run", + "assessments.perf_cone_drill", + ]); + } + + async get(id: string) { + return await VerificationsService.baseQuery() + .where("verifications.id", "=", id) + .executeTakeFirst(); + } + async list(opt: { filter?: { status?: VerificationStatus }, limit?: number, offset?: number, } = {}) { - return await db.selectFrom("verifications") - .selectAll() + return await VerificationsService.baseQuery() .$if(opt.filter?.status !== undefined, (qb) => qb - .where("status", "=", opt.filter!.status!) + .where("verifications.status", "=", opt.filter!.status!) ) - .orderBy("created_at", "desc") + .orderBy("verifications.created_at", "desc") .$if(opt.limit !== undefined, (qb) => qb.limit(opt.limit!)) .$if(opt.offset !== undefined, (qb) => qb.offset(opt.offset!)) .execute(); diff --git a/apps/portal/src/routes/(app)/+layout.svelte b/apps/portal/src/routes/(app)/+layout.svelte index cd8a50e..dfd81d6 100644 --- a/apps/portal/src/routes/(app)/+layout.svelte +++ b/apps/portal/src/routes/(app)/+layout.svelte @@ -25,6 +25,11 @@ path: "/activity", links: [{ name: "Events", path: "/events" }], }, + { + name: "Verification", + path: "/verification", + links: [{ name: "Requests", path: "/requests" }], + }, ]; let { children } = $props(); diff --git a/apps/portal/src/routes/(app)/verification/requests/+page.svelte b/apps/portal/src/routes/(app)/verification/requests/+page.svelte new file mode 100644 index 0000000..2820972 --- /dev/null +++ b/apps/portal/src/routes/(app)/verification/requests/+page.svelte @@ -0,0 +1,171 @@ + + +
+ + + + +
+ + + +
+ +{#if loadError} + +{/if} + +
+ + + + + + + + + + + + {#each requests as row (row.id)} + + + + + + + + {:else} + + + + {/each} + +
PlayerStatusVideosRequested
{row.name} +
+ {row.status} +
+
{videoCount(row)} / {Object.values(Activity).length}{formatDate(row.created_at)} + + Review + +
+ {loading ? "Loading..." : "No verification requests"} +
+
diff --git a/apps/portal/src/routes/(app)/verification/requests/[id]/+page.svelte b/apps/portal/src/routes/(app)/verification/requests/[id]/+page.svelte new file mode 100644 index 0000000..3b1b9c4 --- /dev/null +++ b/apps/portal/src/routes/(app)/verification/requests/[id]/+page.svelte @@ -0,0 +1,446 @@ + + + + +{#if loading} +
+ +
+{:else if loadError} + +{:else if verification} + {@const v = verification} +
+

{v.name}

+
{v.status}
+ {v.gender} · age {v.age} · {Math.round(kgToLb(v.weight))} lb +
+ {reviewedCount} / {ACTIVITIES.length} reviewed +
+ +
+ {#each ACTIVITIES as activity, i (activity)} + + {/each} + +
+ + {#if !isSummaryStep} + {@const activity = ACTIVITIES[stepIndex]} +
+
+

{activity}

+ Claimed: {formatPerformance( + activity, + v[PERF_COLUMN[activity]], + )} +
+ + {#if v[URL_COLUMN[activity]]} + {@const videoUrl = v[URL_COLUMN[activity]]} + + + + Open video in new tab + + {:else} +
+ No video uploaded +
+ {/if} + +
+ + + +
+ +
+ + +
+
+ {:else} +
+

Review summary

+ +
+ + + + + + + + + + + {#each ACTIVITIES as activity, i (activity)} + + + + + + + {/each} + +
ActivityClaimedVideoResult
+ + + {formatPerformance( + activity, + v[PERF_COLUMN[activity]], + )} + + {#if v[URL_COLUMN[activity]]} + + View + + {:else} + None + {/if} + + {#if verf[activity] === "pass"} + Pass + {:else if verf[activity] === "fail"} + Fail + {:else} + Unreviewed + {/if} +
+
+ + + + {#if submitError} + + {/if} + +
+ + +
+ {#if !allReviewed} + Mark every activity Pass or Fail to complete + {/if} + + +
+
+
+ {/if} +{/if}