From f593aabd25740f52a43061bc242a281f7dfd7047 Mon Sep 17 00:00:00 2001 From: Dominic Ferrando Date: Sat, 19 Sep 2026 16:16:50 -0400 Subject: [PATCH] Implement basic verifications API endpoints --- .../api/src/database/migrations/2026-09-19.ts | 2 +- apps/api/src/server.ts | 56 +++++++++++++++++- apps/api/src/services/verifications.ts | 57 ++++++++++++++++++- packages/domain/src/models.ts | 3 + 4 files changed, 113 insertions(+), 5 deletions(-) diff --git a/apps/api/src/database/migrations/2026-09-19.ts b/apps/api/src/database/migrations/2026-09-19.ts index f0e1f57..e9d3f93 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_required | completed + .addColumn("status", "text", (cb) => cb.notNull().defaultTo("request")) // request | in_review | action_requested | completed .addColumn("completed_at", "timestamptz") .addColumn("completed_by", "uuid") .addColumn("url_back_squat", "text") diff --git a/apps/api/src/server.ts b/apps/api/src/server.ts index 3bb8d25..3afeca5 100644 --- a/apps/api/src/server.ts +++ b/apps/api/src/server.ts @@ -1,5 +1,7 @@ import { ActivityPerformanceSchema, + ActivityVerificationsSchema, + ActivityVideosSchema, PlayerSchema, } from "@blade-and-brawn/domain" import { cors } from "@elysiajs/cors"; @@ -23,6 +25,7 @@ import { EventsService, EventStatusSchema } from "./services/events"; import { AccountsService, type AccountRole } from "./services/accounts"; import { Value } from "@sinclair/typebox/value"; import { AssessmentsService } from "./services/assessments"; +import { VerificationsService, VerificationStatusSchema } from "./services/verifications"; import { Not } from "@sinclair/typebox"; // CONSTANTS @@ -40,7 +43,8 @@ const s = (() => { const Accounts = new AccountsService(Calculator); const Events = new EventsService(); const Assessments = new AssessmentsService(); - return { Standards, Calculator, Commerce, Accounts, Events, Assessments }; + const Verifications = new VerificationsService(); + return { Standards, Calculator, Commerce, Accounts, Events, Assessments, Verifications }; })(); // QUEUES @@ -523,6 +527,56 @@ export const app = new Elysia() ) ) + // VERIFICATIONS + .group("/verifications", (app) => app + .guard({ auth: true }, (app) => app + .post("/me", async ({ body: { assessmentId, activityVideoUrls }, accountId }) => { + const requested = await s.Verifications.request(assessmentId, accountId, activityVideoUrls); + if (!requested) throw new NotFoundError("Assessment not found"); + return requested; + }, { + body: t.Object({ + assessmentId: t.String(), + activityVideoUrls: ActivityVideosSchema, + }) + }) + ) + .guard({ authAdmin: true }, (app) => app + .get("/", async ({ query }) => { + return await s.Verifications.list({ + filter: { status: query.status }, + limit: query.limit, + offset: query.offset, + }); + }, { + query: t.Object({ + status: t.Optional(VerificationStatusSchema), + limit: t.Optional(t.Numeric()), + offset: t.Optional(t.Numeric()), + }) + }) + .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"); + }, { + params: t.Object({ id: t.String() }), + body: t.Object({ + reviewerNotes: t.Optional(t.String()), + activityVerifications: ActivityVerificationsSchema, + }) + }) + .post("/:id/complete", async ({ params: { id }, body: { activityVerifications }, accountId }) => { + const updated = await s.Verifications.complete(id, accountId, activityVerifications); + if (!updated) throw new NotFoundError("Verification not found"); + }, { + params: t.Object({ id: t.String() }), + body: t.Object({ + activityVerifications: ActivityVerificationsSchema, + }) + }) + ) + ) + // WEBHOOKS .post("/webhooks/printful", async ({ body, query }) => { // https://webflow.com/integrations/printful diff --git a/apps/api/src/services/verifications.ts b/apps/api/src/services/verifications.ts index d608fd0..9849d03 100644 --- a/apps/api/src/services/verifications.ts +++ b/apps/api/src/services/verifications.ts @@ -1,10 +1,26 @@ -import { Activity, type ActivityVideoUrls } from "@blade-and-brawn/domain"; +import { Activity, type ActivityVerifications, type ActivityVideoUrls } from "@blade-and-brawn/domain"; +import type { Static } from "@sinclair/typebox"; +import { t } from "elysia"; import { db } from "../database/db"; -export type VerificationStatus = "request" | "in_review" | "action_required" | "completed"; +export const VerificationStatusSchema = t.Union([ + t.Literal("request"), + t.Literal("in_review"), + t.Literal("action_requested"), + t.Literal("completed"), +]); +export type VerificationStatus = Static; export class VerificationsService { - async request(assessmentId: string, activityVideoUrls: ActivityVideoUrls) { + 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; + return await db.insertInto("verifications") .values({ assessment_id: assessmentId, @@ -58,4 +74,39 @@ export class VerificationsService { .$if(opt.offset !== undefined, (qb) => qb.offset(opt.offset!)) .execute(); } + + async requestAction(id: string, reviewerNotes: string | null, activityVerifications: ActivityVerifications) { + const result = await db.updateTable("verifications") + .where("id", "=", id) + .set({ + status: "action_requested" satisfies VerificationStatus, + reviewer_notes: reviewerNotes, + verf_back_squat: activityVerifications[Activity.BackSquat] ?? null, + verf_deadlift: activityVerifications[Activity.Deadlift] ?? null, + verf_bench_press: activityVerifications[Activity.BenchPress] ?? null, + verf_broad_jump: activityVerifications[Activity.BroadJump] ?? null, + verf_run: activityVerifications[Activity.Run] ?? null, + verf_cone_drill: activityVerifications[Activity.ConeDrill] ?? null, + }) + .executeTakeFirst(); + return result.numUpdatedRows > 0n; + } + + async complete(id: string, completedBy: string, activityVerifications: ActivityVerifications) { + const result = await db.updateTable("verifications") + .where("id", "=", id) + .set({ + status: "completed" satisfies VerificationStatus, + completed_at: new Date(), + completed_by: completedBy, + verf_back_squat: activityVerifications[Activity.BackSquat] ?? null, + verf_deadlift: activityVerifications[Activity.Deadlift] ?? null, + verf_bench_press: activityVerifications[Activity.BenchPress] ?? null, + verf_broad_jump: activityVerifications[Activity.BroadJump] ?? null, + verf_run: activityVerifications[Activity.Run] ?? null, + verf_cone_drill: activityVerifications[Activity.ConeDrill] ?? null, + }) + .executeTakeFirst(); + return result.numUpdatedRows > 0n; + } } diff --git a/packages/domain/src/models.ts b/packages/domain/src/models.ts index 295c988..bd483f0 100644 --- a/packages/domain/src/models.ts +++ b/packages/domain/src/models.ts @@ -45,3 +45,6 @@ export type ActivityPerformance = Static; export const ActivityVideosSchema = t.Partial(t.Record(t.Enum(Activity), t.String())); export type ActivityVideoUrls = Static; + +export const ActivityVerificationsSchema = t.Partial(t.Record(t.Enum(Activity), t.Boolean())); +export type ActivityVerifications = Static;