Implementation of verification action endpoints
This commit is contained in:
@@ -7,7 +7,7 @@ export async function up(db: Kysely<any>): Promise<void> {
|
||||
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")
|
||||
|
||||
@@ -73,17 +73,20 @@ async function seedTestAssessment(accountId: string): Promise<string> {
|
||||
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");
|
||||
|
||||
+70
-16
@@ -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({
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<typeof VerificationStatusSchema>;
|
||||
|
||||
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<Activity, keyof DB["verifications"]>;
|
||||
|
||||
const ALLOWED_FROM = {
|
||||
update: ["draft", "action_requested"],
|
||||
submit: ["draft"],
|
||||
requestAction: ["submitted", "in_review"],
|
||||
complete: ["submitted", "in_review"],
|
||||
} satisfies Record<string, VerificationStatus[]>;
|
||||
|
||||
export class VerificationsService {
|
||||
static missingActivityMediaKeys(verification: Pick<Selectable<DB["verifications"]>, (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",
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user