From b66980c0fb566282a7f2b5462a769d2f55cfb319 Mon Sep 17 00:00:00 2001 From: Dominic Ferrando Date: Sat, 26 Sep 2026 17:59:34 -0400 Subject: [PATCH] Implementation of verification action endpoints --- .../api/src/database/migrations/2026-09-19.ts | 2 +- .../api/src/scripts/seed-test-verification.ts | 21 +- apps/api/src/server.ts | 86 ++++++-- apps/api/src/services/assessments.ts | 7 + apps/api/src/services/verifications.ts | 202 ++++++++++-------- .../(app)/verification/requests/+page.svelte | 8 +- .../verification/requests/[id]/+page.svelte | 11 +- packages/domain/src/models.ts | 4 +- 8 files changed, 222 insertions(+), 119 deletions(-) diff --git a/apps/api/src/database/migrations/2026-09-19.ts b/apps/api/src/database/migrations/2026-09-19.ts index 369fba5..a251af5 100644 --- a/apps/api/src/database/migrations/2026-09-19.ts +++ b/apps/api/src/database/migrations/2026-09-19.ts @@ -7,7 +7,7 @@ export async function up(db: Kysely): Promise { await db.schema.createTable("verifications") .$call(addDefaultColumns) .addColumn("assessment_id", "bigint", (cb) => cb.notNull().unique()) - .addColumn("status", "text", (cb) => cb.notNull().defaultTo("request")) // request | in_review | action_requested | completed + .addColumn("status", "text", (cb) => cb.notNull().defaultTo("draft")) // draft | submitted | in_review | action_requested | completed .addColumn("completed_at", "timestamptz") .addColumn("completed_by", "uuid") .addColumn("media_key_back_squat", "text") diff --git a/apps/api/src/scripts/seed-test-verification.ts b/apps/api/src/scripts/seed-test-verification.ts index ff94e22..8de5917 100644 --- a/apps/api/src/scripts/seed-test-verification.ts +++ b/apps/api/src/scripts/seed-test-verification.ts @@ -73,17 +73,20 @@ async function seedTestAssessment(accountId: string): Promise { 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", + const verification = await verifications.create(assessmentId, { + [Activity.BackSquat]: `verifications/${assessmentId}/back-squat.mp4`, + [Activity.Deadlift]: `verifications/${assessmentId}/deadlift.mp4`, + [Activity.BenchPress]: `verifications/${assessmentId}/bench-press.mp4`, + [Activity.Run]: `verifications/${assessmentId}/run.png`, + [Activity.BroadJump]: `verifications/${assessmentId}/broad-jump.mp4`, + [Activity.ConeDrill]: `verifications/${assessmentId}/cone-drill.mp4`, }); - if (!verification) throw new Error("Failed to seed verification request — assessment ownership check failed"); + if (!verification) throw new Error("Test assessment already has a verification; clear the verifications table first"); - log.info({ id: verification.id, assessmentId, accountId }, "seeded test verification request"); + const submitted = await verifications.submit(verification.id); + if (!submitted) throw new Error("Failed to submit test verification"); + + log.info({ id: submitted.id, status: submitted.status, assessmentId, accountId }, "seeded test verification request"); } catch (err) { log.error({ err }, "seed failed"); diff --git a/apps/api/src/server.ts b/apps/api/src/server.ts index a361168..d7031d4 100644 --- a/apps/api/src/server.ts +++ b/apps/api/src/server.ts @@ -1,7 +1,7 @@ import { ActivityPerformanceSchema, ActivityVerificationsSchema, - ActivityVideosSchema, + ActivityMediaKeysSchema, PlayerSchema, } from "@blade-and-brawn/domain" import { cors } from "@elysiajs/cors"; @@ -488,6 +488,7 @@ export const app = new Elysia() activityPerformances: t.Array(ActivityPerformanceSchema) }) }) + // TODO: handle @ formatted accountIds .get("/assessments", async ({ accountId }) => { return await s.Assessments.list({ filter: { accountId } }); }) @@ -497,16 +498,71 @@ export const app = new Elysia() }, { params: t.Object({ id: t.String() }) }) - .post("/verifications", async ({ body: { assessmentId, activityVideoUrls }, accountId }) => { - const requested = await s.Verifications.request(assessmentId, accountId, activityVideoUrls); - if (!requested) throw new NotFoundError("Assessment not found"); - return requested; + .post("/verifications", async ({ body: { assessmentId, activityMediaKeys }, accountId }) => { + const assessment = await s.Assessments.get(assessmentId); + if (!assessment || assessment.account_id !== accountId) throw new NotFoundError("Assessment not found"); + + // TODO: only allow a single active non-completed verification at a time + // TODO: return R2 POST urls for the media + + const created = await s.Verifications.create(assessmentId, activityMediaKeys); + if (!created) throw status(409, { error: "This assessment already has a verification" }); + return created; }, { body: t.Object({ assessmentId: t.String(), - activityVideoUrls: ActivityVideosSchema, + activityMediaKeys: ActivityMediaKeysSchema, }) }) + .patch("/verifications/:id", async ({ params: { id }, body: { assessmentId, activityMediaKeys }, accountId }) => { + const verification = await s.Verifications.get(id); + if (!verification || verification.account_id !== accountId) throw new NotFoundError("Verification not found"); + + if (assessmentId === undefined && Object.keys(activityMediaKeys ?? {}).length === 0) + throw status(400, { error: "Nothing to update" }); + + if (assessmentId !== undefined) { + const assessment = await s.Assessments.get(assessmentId); + if (!assessment || assessment.account_id !== accountId) throw new NotFoundError("Assessment not found"); + } + + const updated = await s.Verifications.update(id, { assessmentId, activityMediaKeys }) + .catch((err) => { + if (err?.code === "23505") throw status(409, { error: "That assessment already has a verification" }); + throw err; + }); + if (!updated) throw status(409, { error: `Cannot update a ${verification.status} verification` }); + return updated; + }, { + params: t.Object({ id: t.String() }), + body: t.Object({ + assessmentId: t.Optional(t.String()), + activityMediaKeys: t.Optional(ActivityMediaKeysSchema), + }) + }) + .post("/verifications/:id/submit", async ({ params: { id }, accountId }) => { + const verification = await s.Verifications.get(id); + if (!verification || verification.account_id !== accountId) throw new NotFoundError("Verification not found"); + + const submitted = await s.Verifications.submit(id); + if (submitted) { + // TODO: trigger discord webhook here + return submitted; + } + + const current = await s.Verifications.get(id); + if (!current) throw new NotFoundError("Verification not found"); + if (current.status === "submitted") return { id: current.id, status: current.status }; + + const missingActivityMediaKeys = VerificationsService.missingActivityMediaKeys(current); + throw status(409, { + error: current.status === "draft" && missingActivityMediaKeys.length > 0 + ? `Missing media for: ${missingActivityMediaKeys.join(", ")}` + : `Cannot submit a ${current.status} verification` + }); + }, { + params: t.Object({ id: t.String() }) + }) ) ) @@ -556,13 +612,12 @@ export const app = new Elysia() 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) return updated; + const verification = await s.Verifications.get(id); if (!verification) throw new NotFoundError("Verification not found"); - if (verification.status === "completed" || verification.status === "action_requested") - throw status(409, { error: "Cannot modify a completed or action-requested verification" }); - - const updated = await s.Verifications.requestAction(id, reviewerNotes ?? null, activityVerifications); - if (!updated) throw new NotFoundError("Verification not found"); + throw status(409, { error: `Cannot request action on a ${verification.status} verification` }); }, { params: t.Object({ id: t.String() }), body: t.Object({ @@ -571,13 +626,12 @@ export const app = new Elysia() }) }) .post("/:id/complete", async ({ params: { id }, body: { activityVerifications }, accountId }) => { + const updated = await s.Verifications.complete(id, accountId, activityVerifications); + if (updated) return updated; + const verification = await s.Verifications.get(id); if (!verification) throw new NotFoundError("Verification not found"); - if (verification.status === "completed" || verification.status === "action_requested") - throw status(409, { error: "Cannot modify a completed or action-requested verification" }); - - const updated = await s.Verifications.complete(id, accountId, activityVerifications); - if (!updated) throw new NotFoundError("Verification not found"); + throw status(409, { error: `Cannot complete a ${verification.status} verification` }); }, { params: t.Object({ id: t.String() }), body: t.Object({ diff --git a/apps/api/src/services/assessments.ts b/apps/api/src/services/assessments.ts index d6b2572..c651057 100644 --- a/apps/api/src/services/assessments.ts +++ b/apps/api/src/services/assessments.ts @@ -24,6 +24,13 @@ export class AssessmentsService { .executeTakeFirstOrThrow(); } + async get(id: string) { + return await db.selectFrom("assessments") + .selectAll() + .where("id", "=", id) + .executeTakeFirst(); + } + async list(opt: { filter?: { accountId?: string }, limit?: number, diff --git a/apps/api/src/services/verifications.ts b/apps/api/src/services/verifications.ts index c2cbb50..f6585bd 100644 --- a/apps/api/src/services/verifications.ts +++ b/apps/api/src/services/verifications.ts @@ -1,100 +1,83 @@ -import { Activity, type ActivityVerifications, type ActivityVideoUrls } from "@blade-and-brawn/domain"; +import { Activity, type ActivityVerifications, type ActivityMediaKeys } from "@blade-and-brawn/domain"; import type { Static } from "@sinclair/typebox"; import { t } from "elysia"; +import type { Selectable } from "kysely"; import { db } from "../database/db"; +import type { DB } from "../database/out/db"; export const VerificationStatusSchema = t.Union([ - t.Literal("request"), + t.Literal("draft"), + t.Literal("submitted"), t.Literal("in_review"), t.Literal("action_requested"), t.Literal("completed"), ]); export type VerificationStatus = Static; -export class VerificationsService { - async request(assessmentId: string, accountId: string, activityVideoUrls: ActivityVideoUrls) { - // Only the owning account may request verification of their own assessment. - const assessment = await db.selectFrom("assessments") - .select("id") - .where("id", "=", assessmentId) - .where("account_id", "=", accountId) - .executeTakeFirst(); - if (!assessment) return undefined; +export const REQUIRED_MEDIA_ACTIVITIES = Object.values(Activity); +const MEDIA_KEY_COLUMN = { + [Activity.BackSquat]: "media_key_back_squat", + [Activity.Deadlift]: "media_key_deadlift", + [Activity.BenchPress]: "media_key_bench_press", + [Activity.BroadJump]: "media_key_broad_jump", + [Activity.Run]: "media_key_run", + [Activity.ConeDrill]: "media_key_cone_drill", +} as const satisfies Record; + +const ALLOWED_FROM = { + update: ["draft", "action_requested"], + submit: ["draft"], + requestAction: ["submitted", "in_review"], + complete: ["submitted", "in_review"], +} satisfies Record; + +export class VerificationsService { + static missingActivityMediaKeys(verification: Pick, (typeof MEDIA_KEY_COLUMN)[Activity]>) { + return REQUIRED_MEDIA_ACTIVITIES.filter((activity) => !verification[MEDIA_KEY_COLUMN[activity]]); + } + + async create(assessmentId: string, activityMediaKeys: ActivityMediaKeys) { return await db.insertInto("verifications") .values({ assessment_id: assessmentId, - status: "request" satisfies VerificationStatus, - media_key_back_squat: activityVideoUrls[Activity.BackSquat] ?? null, - media_key_deadlift: activityVideoUrls[Activity.Deadlift] ?? null, - media_key_bench_press: activityVideoUrls[Activity.BenchPress] ?? null, - media_key_broad_jump: activityVideoUrls[Activity.BroadJump] ?? null, - media_key_run: activityVideoUrls[Activity.Run] ?? null, - media_key_cone_drill: activityVideoUrls[Activity.ConeDrill] ?? null, + status: "draft" satisfies VerificationStatus, + media_key_back_squat: activityMediaKeys[Activity.BackSquat] ?? null, + media_key_deadlift: activityMediaKeys[Activity.Deadlift] ?? null, + media_key_bench_press: activityMediaKeys[Activity.BenchPress] ?? null, + media_key_broad_jump: activityMediaKeys[Activity.BroadJump] ?? null, + media_key_run: activityMediaKeys[Activity.Run] ?? null, + media_key_cone_drill: activityMediaKeys[Activity.ConeDrill] ?? null, }) - .onConflict((oc) => oc - .column("assessment_id") - .doUpdateSet((eb) => ({ - status: "request" satisfies VerificationStatus, - media_key_back_squat: eb.ref("excluded.media_key_back_squat"), - media_key_deadlift: eb.ref("excluded.media_key_deadlift"), - media_key_bench_press: eb.ref("excluded.media_key_bench_press"), - media_key_broad_jump: eb.ref("excluded.media_key_broad_jump"), - media_key_run: eb.ref("excluded.media_key_run"), - media_key_cone_drill: eb.ref("excluded.media_key_cone_drill"), - // Re-requesting means new videos are under review, so the - // previous cycle's per-activity judgments and completion - // info no longer apply. - verf_back_squat: null, - verf_deadlift: null, - verf_bench_press: null, - verf_broad_jump: null, - verf_run: null, - verf_cone_drill: null, - completed_at: null, - completed_by: null, - })) - ) - .returning("id") - .executeTakeFirstOrThrow(); + .onConflict((oc) => oc.column("assessment_id").doNothing()) + .returning(["id", "status"]) + .executeTakeFirst(); } - // 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.media_key_back_squat", - "verifications.media_key_deadlift", - "verifications.media_key_bench_press", - "verifications.media_key_broad_jump", - "verifications.media_key_run", - "verifications.media_key_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 update(id: string, { assessmentId, activityMediaKeys = {} }: { assessmentId?: string, activityMediaKeys?: ActivityMediaKeys }) { + const replaced = (activity: Activity) => activityMediaKeys[activity] ? null : undefined; + + return await db.updateTable("verifications") + .where("id", "=", id) + .where("status", "in", ALLOWED_FROM.update) + .set({ + status: "draft" satisfies VerificationStatus, + assessment_id: assessmentId, + media_key_back_squat: activityMediaKeys[Activity.BackSquat], + media_key_deadlift: activityMediaKeys[Activity.Deadlift], + media_key_bench_press: activityMediaKeys[Activity.BenchPress], + media_key_broad_jump: activityMediaKeys[Activity.BroadJump], + media_key_run: activityMediaKeys[Activity.Run], + media_key_cone_drill: activityMediaKeys[Activity.ConeDrill], + verf_back_squat: replaced(Activity.BackSquat), + verf_deadlift: replaced(Activity.Deadlift), + verf_bench_press: replaced(Activity.BenchPress), + verf_broad_jump: replaced(Activity.BroadJump), + verf_run: replaced(Activity.Run), + verf_cone_drill: replaced(Activity.ConeDrill), + }) + .returning(["id", "status"]) + .executeTakeFirst(); } async get(id: string) { @@ -118,9 +101,22 @@ export class VerificationsService { .execute(); } - async requestAction(id: string, reviewerNotes: string | null, activityVerifications: ActivityVerifications) { - const result = await db.updateTable("verifications") + async submit(id: string) { + return await db.updateTable("verifications") .where("id", "=", id) + .where("status", "in", ALLOWED_FROM.submit) + .where((eb) => eb.and( + REQUIRED_MEDIA_ACTIVITIES.map((activity) => eb(MEDIA_KEY_COLUMN[activity], "is not", null)) + )) + .set({ status: "submitted" satisfies VerificationStatus }) + .returning(["id", "status"]) + .executeTakeFirst(); + } + + async requestAction(id: string, reviewerNotes: string | null, activityVerifications: ActivityVerifications) { + return await db.updateTable("verifications") + .where("id", "=", id) + .where("status", "in", ALLOWED_FROM.requestAction) .set({ status: "action_requested" satisfies VerificationStatus, reviewer_notes: reviewerNotes, @@ -131,13 +127,14 @@ export class VerificationsService { verf_run: activityVerifications[Activity.Run] ?? null, verf_cone_drill: activityVerifications[Activity.ConeDrill] ?? null, }) + .returning(["id", "status"]) .executeTakeFirst(); - return result.numUpdatedRows > 0n; } async complete(id: string, completedBy: string, activityVerifications: ActivityVerifications) { - const result = await db.updateTable("verifications") + return await db.updateTable("verifications") .where("id", "=", id) + .where("status", "in", ALLOWED_FROM.complete) .set({ status: "completed" satisfies VerificationStatus, completed_at: new Date(), @@ -149,7 +146,44 @@ export class VerificationsService { verf_run: activityVerifications[Activity.Run] ?? null, verf_cone_drill: activityVerifications[Activity.ConeDrill] ?? null, }) + .returning(["id", "status"]) .executeTakeFirst(); - return result.numUpdatedRows > 0n; + } + + 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.media_key_back_squat", + "verifications.media_key_deadlift", + "verifications.media_key_bench_press", + "verifications.media_key_broad_jump", + "verifications.media_key_run", + "verifications.media_key_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.account_id", + "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", + ]); } } diff --git a/apps/portal/src/routes/(app)/verification/requests/+page.svelte b/apps/portal/src/routes/(app)/verification/requests/+page.svelte index ada5776..d6a4588 100644 --- a/apps/portal/src/routes/(app)/verification/requests/+page.svelte +++ b/apps/portal/src/routes/(app)/verification/requests/+page.svelte @@ -11,7 +11,8 @@ const LIMIT = 25; const STATUS_BADGE: Record = { - request: "badge-info", + draft: "badge-ghost", + submitted: "badge-info", in_review: "badge-warning", action_requested: "badge-error", completed: "badge-success", @@ -47,7 +48,7 @@ let loading = $state(true); let loadError = $state(null); let offset = $state(0); - let status = $state("request"); + let status = $state("submitted"); async function refresh() { loading = true; @@ -91,7 +92,8 @@ onchange={(e) => setStatus(e.currentTarget.value)} > - + + diff --git a/apps/portal/src/routes/(app)/verification/requests/[id]/+page.svelte b/apps/portal/src/routes/(app)/verification/requests/[id]/+page.svelte index 1f48f07..9318e53 100644 --- a/apps/portal/src/routes/(app)/verification/requests/[id]/+page.svelte +++ b/apps/portal/src/routes/(app)/verification/requests/[id]/+page.svelte @@ -19,7 +19,8 @@ const ACTIVITIES = Object.values(Activity); const STATUS_BADGE: Record = { - request: "badge-info", + draft: "badge-ghost", + submitted: "badge-info", in_review: "badge-warning", action_requested: "badge-error", completed: "badge-success", @@ -126,8 +127,8 @@ ); const allReviewed = $derived(reviewedCount === ACTIVITIES.length); const isReadOnly = $derived( - verification?.status === "completed" || - verification?.status === "action_requested", + verification?.status !== "submitted" && + verification?.status !== "in_review", ); async function load() { @@ -432,7 +433,9 @@ {v.status === "completed" ? "This verification is completed and read-only." - : "This verification is waiting on the player to resubmit and is read-only."} + : v.status === "draft" + ? "This verification is still a draft (the player hasn't finished uploading) and is read-only." + : "This verification is waiting on the player to resubmit and is read-only."} {:else}
diff --git a/packages/domain/src/models.ts b/packages/domain/src/models.ts index bd483f0..dc6765c 100644 --- a/packages/domain/src/models.ts +++ b/packages/domain/src/models.ts @@ -43,8 +43,8 @@ export const ActivityPerformanceSchema = t.Object({ }); export type ActivityPerformance = Static; -export const ActivityVideosSchema = t.Partial(t.Record(t.Enum(Activity), t.String())); -export type ActivityVideoUrls = Static; +export const ActivityMediaKeysSchema = t.Partial(t.Record(t.Enum(Activity), t.String({ minLength: 1 }))); +export type ActivityMediaKeys = Static; export const ActivityVerificationsSchema = t.Partial(t.Record(t.Enum(Activity), t.Boolean())); export type ActivityVerifications = Static;