From ea256f33eac29055dd13b504ced0fcf28d9c6383 Mon Sep 17 00:00:00 2001 From: Dominic Ferrando Date: Sat, 19 Sep 2026 16:04:03 -0400 Subject: [PATCH] Add VerificationsService with some basic methods --- .../api/src/database/migrations/2026-09-19.ts | 14 ++--- apps/api/src/services/verifications.ts | 61 +++++++++++++++++++ packages/domain/src/models.ts | 3 + 3 files changed, 71 insertions(+), 7 deletions(-) create mode 100644 apps/api/src/services/verifications.ts diff --git a/apps/api/src/database/migrations/2026-09-19.ts b/apps/api/src/database/migrations/2026-09-19.ts index cb4a312..f0e1f57 100644 --- a/apps/api/src/database/migrations/2026-09-19.ts +++ b/apps/api/src/database/migrations/2026-09-19.ts @@ -3,8 +3,8 @@ import { Kysely, sql } from 'kysely' import { addDefaultColumns } from '../db'; export async function up(db: Kysely): Promise { - // TABLE: ASSESSMENT_VERIFICATIONS - await db.schema.createTable("assessment_verifications") + // TABLE: VERIFICATIONS + 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_required | completed @@ -24,27 +24,27 @@ export async function up(db: Kysely): Promise { .addColumn("verf_cone_drill", "boolean") .addColumn("reviewer_notes", "text") // can be structured in the future, for now keep simple .addForeignKeyConstraint( - "fk_assessment_verifications_assessment_id", + "fk_verifications_assessment_id", ["assessment_id"], "assessments", ["id"], (cb) => cb.onDelete("cascade") ) .addForeignKeyConstraint( - "fk_assessment_verifications_completed_by", + "fk_verifications_completed_by", ["completed_by"], "accounts", ["id"], (cb) => cb.onDelete("restrict") ) .addCheckConstraint( - "chk_assessment_verifications_completed_fields", + "chk_verifications_completed_fields", sql`status != 'completed' OR (completed_at IS NOT NULL AND completed_by IS NOT NULL)` ) .execute() } export async function down(db: Kysely): Promise { - // TABLE: ASSESSMENT_VERIFICATIONS - await db.schema.dropTable("assessment_verifications").ifExists().execute() + // TABLE: VERIFICATIONS + await db.schema.dropTable("verifications").ifExists().execute() } diff --git a/apps/api/src/services/verifications.ts b/apps/api/src/services/verifications.ts new file mode 100644 index 0000000..d608fd0 --- /dev/null +++ b/apps/api/src/services/verifications.ts @@ -0,0 +1,61 @@ +import { Activity, type ActivityVideoUrls } from "@blade-and-brawn/domain"; +import { db } from "../database/db"; + +export type VerificationStatus = "request" | "in_review" | "action_required" | "completed"; + +export class VerificationsService { + async request(assessmentId: string, activityVideoUrls: ActivityVideoUrls) { + return await db.insertInto("verifications") + .values({ + assessment_id: assessmentId, + status: "request" satisfies VerificationStatus, + url_back_squat: activityVideoUrls[Activity.BackSquat] ?? null, + url_deadlift: activityVideoUrls[Activity.Deadlift] ?? null, + url_bench_press: activityVideoUrls[Activity.BenchPress] ?? null, + url_broad_jump: activityVideoUrls[Activity.BroadJump] ?? null, + url_run: activityVideoUrls[Activity.Run] ?? null, + url_cone_drill: activityVideoUrls[Activity.ConeDrill] ?? null, + }) + .onConflict((oc) => oc + .column("assessment_id") + .doUpdateSet((eb) => ({ + status: "request" satisfies VerificationStatus, + url_back_squat: eb.ref("excluded.url_back_squat"), + url_deadlift: eb.ref("excluded.url_deadlift"), + url_bench_press: eb.ref("excluded.url_bench_press"), + url_broad_jump: eb.ref("excluded.url_broad_jump"), + url_run: eb.ref("excluded.url_run"), + url_cone_drill: eb.ref("excluded.url_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(); + } + + async list(opt: { + filter?: { status?: VerificationStatus }, + limit?: number, + offset?: number, + } = {}) { + return await db.selectFrom("verifications") + .selectAll() + .$if(opt.filter?.status !== undefined, (qb) => qb + .where("status", "=", opt.filter!.status!) + ) + .orderBy("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/packages/domain/src/models.ts b/packages/domain/src/models.ts index 3bfd23d..295c988 100644 --- a/packages/domain/src/models.ts +++ b/packages/domain/src/models.ts @@ -42,3 +42,6 @@ export const ActivityPerformanceSchema = t.Object({ performance: t.Number(), }); export type ActivityPerformance = Static; + +export const ActivityVideosSchema = t.Partial(t.Record(t.Enum(Activity), t.String())); +export type ActivityVideoUrls = Static;