Add a verification interface for review

This commit is contained in:
Dominic Ferrando
2026-09-19 17:57:52 -04:00
parent f593aabd25
commit dc7ebd6873
6 changed files with 772 additions and 4 deletions
@@ -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<string> {
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<string> {
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();
}
})();
+8
View File
@@ -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";
+47 -4
View File
@@ -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();
@@ -25,6 +25,11 @@
path: "/activity",
links: [{ name: "Events", path: "/events" }],
},
{
name: "Verification",
path: "/verification",
links: [{ name: "Requests", path: "/requests" }],
},
];
let { children } = $props();
@@ -0,0 +1,171 @@
<script lang="ts">
import { api } from "$lib/api";
import { Activity } from "@blade-and-brawn/domain";
import type { VerificationStatus } from "@blade-and-brawn/api";
import { onMount } from "svelte";
type VerificationRow = NonNullable<
Awaited<ReturnType<typeof api.verifications.get>>["data"]
>[number];
const LIMIT = 25;
const STATUS_BADGE: Record<VerificationStatus, string> = {
request: "badge-info",
in_review: "badge-warning",
action_requested: "badge-error",
completed: "badge-success",
};
const URL_COLUMN = {
[Activity.BackSquat]: "url_back_squat",
[Activity.Deadlift]: "url_deadlift",
[Activity.BenchPress]: "url_bench_press",
[Activity.Run]: "url_run",
[Activity.BroadJump]: "url_broad_jump",
[Activity.ConeDrill]: "url_cone_drill",
} as const satisfies Record<Activity, keyof VerificationRow>;
function videoCount(row: VerificationRow): number {
return Object.values(Activity).filter((a) => row[URL_COLUMN[a]]).length;
}
function formatDate(value: string | Date | null): string {
if (!value) return "—";
return new Date(value).toLocaleString();
}
function errorMessage(err: unknown): string {
const value = (err as { value?: { error?: string } })?.value;
return (
value?.error ??
(err instanceof Error ? err.message : "Unknown error")
);
}
let requests = $state<VerificationRow[]>([]);
let loading = $state(true);
let loadError = $state<string | null>(null);
let offset = $state(0);
let status = $state<VerificationStatus | "">("request");
async function refresh() {
loading = true;
loadError = null;
try {
const res = await api.verifications.get({
query: { status: status || undefined, limit: LIMIT, offset },
});
if (res.error) throw res.error;
requests = res.data as VerificationRow[];
} catch (err) {
loadError = errorMessage(err);
} finally {
loading = false;
}
}
function setStatus(value: string) {
status = value as VerificationStatus | "";
offset = 0;
refresh();
}
function prevPage() {
offset = Math.max(0, offset - LIMIT);
refresh();
}
function nextPage() {
offset += LIMIT;
refresh();
}
onMount(refresh);
</script>
<div class="w-full flex items-center gap-2 flex-wrap mt-10 mb-5">
<select
class="select select-sm w-44"
value={status}
onchange={(e) => setStatus(e.currentTarget.value)}
>
<option value="">All statuses</option>
<option value="request">Request</option>
<option value="action_requested">Action requested</option>
<option value="completed">Completed</option>
</select>
<button
class="btn btn-secondary btn-sm"
onclick={refresh}
disabled={loading}
>
{loading ? "Refreshing..." : "Refresh"}
</button>
<div class="flex-1"></div>
<button
class="btn btn-neutral btn-sm"
onclick={prevPage}
disabled={offset === 0 || loading}
>
Prev
</button>
<button
class="btn btn-neutral btn-sm"
onclick={nextPage}
disabled={requests.length < LIMIT || loading}
>
Next
</button>
</div>
{#if loadError}
<div role="alert" class="alert alert-error w-full mb-4">
<span>{loadError}</span>
</div>
{/if}
<div class="w-full overflow-x-auto">
<table class="table">
<thead>
<tr>
<th>Player</th>
<th>Status</th>
<th>Videos</th>
<th>Requested</th>
<th></th>
</tr>
</thead>
<tbody>
{#each requests as row (row.id)}
<tr class="hover:bg-base-300">
<td>{row.name}</td>
<td>
<div class="badge {STATUS_BADGE[row.status]}">
{row.status}
</div>
</td>
<td>{videoCount(row)} / {Object.values(Activity).length}</td>
<td class="whitespace-nowrap">{formatDate(row.created_at)}</td>
<td>
<a
class="btn btn-primary btn-xs"
href="/verification/requests/{row.id}"
>
Review
</a>
</td>
</tr>
{:else}
<tr>
<td colspan="5" class="text-center opacity-60">
{loading ? "Loading..." : "No verification requests"}
</td>
</tr>
{/each}
</tbody>
</table>
</div>
@@ -0,0 +1,446 @@
<script lang="ts">
import { page } from "$app/state";
import { goto } from "$app/navigation";
import { api } from "$lib/api";
import { Activity, cmToIn, kgToLb, msToTime } from "@blade-and-brawn/domain";
import type { ActivityVerifications } from "@blade-and-brawn/domain";
import type { VerificationStatus } from "@blade-and-brawn/api";
import { onMount } from "svelte";
async function loadVerification(id: string) {
return await api.verifications({ id }).get();
}
type VerificationDetail = NonNullable<
Awaited<ReturnType<typeof loadVerification>>["data"]
>;
type VerfChoice = "unset" | "pass" | "fail";
const ACTIVITIES = Object.values(Activity);
const STATUS_BADGE: Record<VerificationStatus, string> = {
request: "badge-info",
in_review: "badge-warning",
action_requested: "badge-error",
completed: "badge-success",
};
const URL_COLUMN = {
[Activity.BackSquat]: "url_back_squat",
[Activity.Deadlift]: "url_deadlift",
[Activity.BenchPress]: "url_bench_press",
[Activity.Run]: "url_run",
[Activity.BroadJump]: "url_broad_jump",
[Activity.ConeDrill]: "url_cone_drill",
} as const satisfies Record<Activity, keyof VerificationDetail>;
const VERF_COLUMN = {
[Activity.BackSquat]: "verf_back_squat",
[Activity.Deadlift]: "verf_deadlift",
[Activity.BenchPress]: "verf_bench_press",
[Activity.Run]: "verf_run",
[Activity.BroadJump]: "verf_broad_jump",
[Activity.ConeDrill]: "verf_cone_drill",
} as const satisfies Record<Activity, keyof VerificationDetail>;
const PERF_COLUMN = {
[Activity.BackSquat]: "perf_back_squat",
[Activity.Deadlift]: "perf_deadlift",
[Activity.BenchPress]: "perf_bench_press",
[Activity.Run]: "perf_run",
[Activity.BroadJump]: "perf_broad_jump",
[Activity.ConeDrill]: "perf_cone_drill",
} as const satisfies Record<Activity, keyof VerificationDetail>;
function formatPerformance(activity: Activity, value: number | null): string {
if (value === null) return "—";
switch (activity) {
case Activity.BackSquat:
case Activity.Deadlift:
case Activity.BenchPress:
return `${Math.round(kgToLb(value))} lb`;
case Activity.Run:
case Activity.ConeDrill:
return msToTime(value, true);
case Activity.BroadJump: {
const inches = cmToIn(value);
const ft = Math.floor(inches / 12);
const rem = Math.round((inches - ft * 12) * 2) / 2;
return `${ft}' ${rem}"`;
}
default:
return "—";
}
}
function verfChoiceFrom(value: boolean | null): VerfChoice {
if (value === true) return "pass";
if (value === false) return "fail";
return "unset";
}
function toActivityVerifications(
choices: Record<Activity, VerfChoice>,
): ActivityVerifications {
const result: ActivityVerifications = {};
for (const activity of ACTIVITIES) {
if (choices[activity] === "pass") result[activity] = true;
else if (choices[activity] === "fail") result[activity] = false;
}
return result;
}
function errorMessage(err: unknown): string {
const value = (err as { value?: { error?: string } })?.value;
return (
value?.error ??
(err instanceof Error ? err.message : "Unknown error")
);
}
const id = page.params.id;
let verification = $state<VerificationDetail | null>(null);
let loading = $state(true);
let loadError = $state<string | null>(null);
let stepIndex = $state(0);
let notes = $state("");
let verf = $state<Record<Activity, VerfChoice>>(
Object.fromEntries(ACTIVITIES.map((a) => [a, "unset"])) as Record<
Activity,
VerfChoice
>,
);
let submitting = $state<"action" | "complete" | undefined>();
let submitError = $state<string | null>(null);
const SUMMARY_STEP = ACTIVITIES.length;
const isSummaryStep = $derived(stepIndex === SUMMARY_STEP);
const reviewedCount = $derived(
ACTIVITIES.filter((a) => verf[a] !== "unset").length,
);
const allReviewed = $derived(reviewedCount === ACTIVITIES.length);
async function load() {
loading = true;
loadError = null;
try {
const res = await loadVerification(id);
if (res.error) throw res.error;
if (!res.data) throw new Error("Verification not found");
verification = res.data;
notes = verification.reviewer_notes ?? "";
verf = Object.fromEntries(
ACTIVITIES.map((a) => [
a,
verfChoiceFrom(verification![VERF_COLUMN[a]]),
]),
) as Record<Activity, VerfChoice>;
} catch (err) {
loadError = errorMessage(err);
} finally {
loading = false;
}
}
function goPrev() {
stepIndex = Math.max(0, stepIndex - 1);
}
function goNext() {
stepIndex = Math.min(SUMMARY_STEP, stepIndex + 1);
}
function setVerf(activity: Activity, choice: VerfChoice) {
verf[activity] = choice;
}
async function requestAction() {
submitting = "action";
submitError = null;
try {
const res = await api
.verifications({ id })
["request-action"].post({
reviewerNotes: notes || undefined,
activityVerifications: toActivityVerifications(verf),
});
if (res.error) throw res.error;
await goto("/verification/requests");
} catch (err) {
submitError = errorMessage(err);
} finally {
submitting = undefined;
}
}
async function complete() {
submitting = "complete";
submitError = null;
try {
const res = await api.verifications({ id }).complete.post({
activityVerifications: toActivityVerifications(verf),
});
if (res.error) throw res.error;
await goto("/verification/requests");
} catch (err) {
submitError = errorMessage(err);
} finally {
submitting = undefined;
}
}
onMount(load);
</script>
<div class="w-full mt-10 mb-5">
<a href="/verification/requests" class="link link-hover text-sm opacity-70"
>&larr; Back to requests</a
>
</div>
{#if loading}
<div class="w-full flex justify-center pt-10">
<span class="loading loading-spinner loading-lg"></span>
</div>
{:else if loadError}
<div role="alert" class="alert alert-error w-full mb-4">
<span>{loadError}</span>
</div>
{:else if verification}
{@const v = verification}
<div class="w-full flex items-center gap-3 flex-wrap mb-6">
<h1 class="text-xl font-semibold">{v.name}</h1>
<div class="badge {STATUS_BADGE[v.status]}">{v.status}</div>
<span class="text-sm opacity-70"
>{v.gender} · age {v.age} · {Math.round(kgToLb(v.weight))} lb</span
>
<div class="flex-1"></div>
<span class="text-sm opacity-70"
>{reviewedCount} / {ACTIVITIES.length} reviewed</span
>
</div>
<div role="tablist" class="tabs tabs-border w-full">
{#each ACTIVITIES as activity, i (activity)}
<button
role="tab"
class="tab gap-2"
class:tab-active={stepIndex === i}
onclick={() => (stepIndex = i)}
>
{activity}
{#if verf[activity] === "pass"}
<span class="text-success">✓</span>
{:else if verf[activity] === "fail"}
<span class="text-error">✕</span>
{/if}
</button>
{/each}
<button
role="tab"
class="tab"
class:tab-active={isSummaryStep}
onclick={() => (stepIndex = SUMMARY_STEP)}
>
Summary
</button>
</div>
{#if !isSummaryStep}
{@const activity = ACTIVITIES[stepIndex]}
<div class="w-full flex flex-col gap-4 bg-base-200/60 rounded-lg p-6 mt-4">
<div class="flex items-center justify-between flex-wrap gap-2">
<h2 class="text-lg font-semibold">{activity}</h2>
<span class="text-sm opacity-70"
>Claimed: {formatPerformance(
activity,
v[PERF_COLUMN[activity]],
)}</span
>
</div>
{#if v[URL_COLUMN[activity]]}
{@const videoUrl = v[URL_COLUMN[activity]]}
<!-- svelte-ignore a11y_media_has_caption -->
<video
class="w-full max-h-[480px] rounded-lg bg-black"
src={videoUrl}
controls
preload="metadata"
></video>
<a
class="link link-primary text-xs self-start"
href={videoUrl}
target="_blank"
rel="noopener noreferrer"
>
Open video in new tab
</a>
{:else}
<div
class="w-full aspect-video rounded-lg bg-base-300 flex items-center justify-center opacity-60"
>
No video uploaded
</div>
{/if}
<div class="flex gap-2 justify-center mt-2">
<button
class="btn btn-sm {verf[activity] === 'pass'
? 'btn-success'
: 'btn-outline btn-success'}"
onclick={() => setVerf(activity, "pass")}
>
Pass
</button>
<button
class="btn btn-sm {verf[activity] === 'fail'
? 'btn-error'
: 'btn-outline btn-error'}"
onclick={() => setVerf(activity, "fail")}
>
Fail
</button>
<button
class="btn btn-sm btn-ghost"
disabled={verf[activity] === "unset"}
onclick={() => setVerf(activity, "unset")}
>
Clear
</button>
</div>
<div class="flex justify-between mt-2">
<button
class="btn btn-neutral btn-sm"
onclick={goPrev}
disabled={stepIndex === 0}
>
&larr; Previous
</button>
<button class="btn btn-neutral btn-sm" onclick={goNext}>
Next &rarr;
</button>
</div>
</div>
{:else}
<div class="w-full flex flex-col gap-4 bg-base-200/60 rounded-lg p-6 mt-4">
<h2 class="text-lg font-semibold">Review summary</h2>
<div class="overflow-x-auto">
<table class="table table-sm">
<thead>
<tr>
<th>Activity</th>
<th>Claimed</th>
<th>Video</th>
<th>Result</th>
</tr>
</thead>
<tbody>
{#each ACTIVITIES as activity, i (activity)}
<tr>
<td>
<button
class="link link-hover"
onclick={() => (stepIndex = i)}
>
{activity}
</button>
</td>
<td>
{formatPerformance(
activity,
v[PERF_COLUMN[activity]],
)}
</td>
<td>
{#if v[URL_COLUMN[activity]]}
<a
class="link link-primary"
href={v[URL_COLUMN[activity]]}
target="_blank"
rel="noopener noreferrer"
>
View
</a>
{:else}
<span class="opacity-50">None</span>
{/if}
</td>
<td>
{#if verf[activity] === "pass"}
<span class="badge badge-success"
>Pass</span
>
{:else if verf[activity] === "fail"}
<span class="badge badge-error"
>Fail</span
>
{:else}
<span class="badge badge-ghost"
>Unreviewed</span
>
{/if}
</td>
</tr>
{/each}
</tbody>
</table>
</div>
<label class="form-control">
<span class="label mb-1 text-xs">Reviewer notes</span>
<textarea
class="textarea textarea-bordered w-full"
rows="3"
placeholder="What needs to be fixed?"
bind:value={notes}
></textarea>
</label>
{#if submitError}
<div role="alert" class="alert alert-error">
<span>{submitError}</span>
</div>
{/if}
<div class="flex items-center gap-2 justify-between">
<button
class="btn btn-neutral btn-sm"
onclick={goPrev}
disabled={stepIndex === 0}
>
&larr; Previous
</button>
<div class="flex items-center gap-2">
{#if !allReviewed}
<span class="text-xs text-warning"
>Mark every activity Pass or Fail to complete</span
>
{/if}
<button
class="btn btn-warning"
disabled={!!submitting}
onclick={requestAction}
>
{submitting === "action"
? "Submitting..."
: "Request action"}
</button>
<button
class="btn btn-success"
disabled={!!submitting || !allReviewed}
onclick={complete}
>
{submitting === "complete"
? "Submitting..."
: "Complete"}
</button>
</div>
</div>
</div>
{/if}
{/if}