diff --git a/apps/api/src/server.ts b/apps/api/src/server.ts index d7031d4..70d6009 100644 --- a/apps/api/src/server.ts +++ b/apps/api/src/server.ts @@ -5,19 +5,20 @@ import { PlayerSchema, } from "@blade-and-brawn/domain" import { cors } from "@elysiajs/cors"; -import { Elysia, NotFoundError, redirect, status, t } from "elysia"; +import { Elysia, redirect, status, t } from "elysia"; import { PrintfulError, WebflowError, Printful, Webflow, } from "@blade-and-brawn/commerce"; -import { DEFAULT_NAME, DUMMY_PASSWORD_HASH, env, log } from "./util"; +import { BigIntIdSchema, DEFAULT_NAME, DUMMY_PASSWORD_HASH, env, log } from "./util"; import serverTiming from "@elysia/server-timing"; import jwt from "@elysia/jwt"; import { CommerceService, WOrderStatusSchema } from "./services/commerce"; import cluster from "node:cluster"; import { randomUUIDv7, sleep } from "bun"; +import { DatabaseError } from "pg"; import { CalculatorService, CalculatorUnavailableError } from "./services/calculator"; import { StandardsParamsSchema } from "@blade-and-brawn/calculator"; import { StandardsService } from "./services/standards"; @@ -62,14 +63,14 @@ const authPlugin = new Elysia({ name: "auth" }) authAdmin: { async resolve({ jwt, cookie: { auth } }) { const token = auth.value && await jwt.verify(auth.value); - if (!token || token.role !== "admin" || !token.accountId || !token.sessionId) throw status(401, "Unauthorized"); + if (!token || token.role !== "admin" || !token.accountId || !token.sessionId) return status(401, { error: "Unauthorized" }); return { role: token.role.toString(), sessionId: token.sessionId.toString(), accountId: token.accountId.toString() }; } }, auth: { async resolve({ jwt, cookie: { auth } }) { const token = auth.value && await jwt.verify(auth.value); - if (!token || !token.role || !token.accountId || !token.sessionId) throw status(401, "Unauthorized"); + if (!token || !token.role || !token.accountId || !token.sessionId) return status(401, { error: "Unauthorized" }); return { role: token.role.toString(), sessionId: token.sessionId.toString(), accountId: token.accountId.toString() }; } } @@ -106,6 +107,11 @@ export const app = new Elysia() }) .onError(({ code, error }) => { + // Database Errors + if (error instanceof DatabaseError && error.code === "23505") + return status(409, { error: "Conflicts with an existing record" }); + + // Custom Errors switch (code) { case "PrintfulError": case "WebflowError": @@ -126,16 +132,18 @@ export const app = new Elysia() } }) - .onAfterResponse(({ request, status, path }) => { + .onAfterResponse(({ request, set, path, responseValue }) => { if (env.NODE_ENV === "development") { const skip: Record = { "/commerce/products/sync/": ["GET"] }; if (skip[path]?.includes(request.method)) return; } - log.info({ + const failed = Number(set.status) >= 400; + log[failed ? "warn" : "info"]({ method: request.method, path, - status + status: set.status, + ...(failed && { response: responseValue }), }, "request"); }) @@ -146,7 +154,7 @@ export const app = new Elysia() .post("/auth/login", async ({ jwt, body: { password, email }, cookie: { auth } }) => { const account = await s.Accounts.getByEmail(email); const password_match = await Bun.password.verify(password, account?.password_hash ?? DUMMY_PASSWORD_HASH); - if (!account || !password_match) throw status(401, "Invalid credentials"); + if (!account || !password_match) return status(401, { error: "Invalid credentials" }); auth.set({ value: await jwt.sign({ role: account.role, sessionId: randomUUIDv7(), accountId: account.id, exp: JWT_EXP }), @@ -184,10 +192,10 @@ export const app = new Elysia() return redirect(url.toString(), 302); }) .get("/callback", async ({ query, cookie: { authDiscord, auth }, jwt }) => { - if (query.error) throw status(400, { error: query.error_description ? `${query.error}: ${query.error_description}` : query.error }); + if (query.error) return status(400, { error: query.error_description ? `${query.error}: ${query.error_description}` : query.error }); - if (!query.state) throw status(400, "No Discord OAuth2 state query parameter provided"); - if (query.state !== authDiscord.value) throw status(400, "Invalid Discord OAuth state"); + if (!query.state) return status(400, { error: "No Discord OAuth2 state query parameter provided" }); + if (query.state !== authDiscord.value) return status(400, { error: "Invalid Discord OAuth state" }); const tokenRes = await fetch("https://discord.com/api/oauth2/token", { method: "POST", @@ -204,7 +212,7 @@ export const app = new Elysia() }); if (!tokenRes.ok) { const errorBody = await tokenRes.json().catch(() => null); - throw status(502, { error: errorBody ?? "Discord token exchange failed" }); + return status(502, { error: errorBody ?? "Discord token exchange failed" }); } const tokenResPayload = await tokenRes.json(); @@ -215,7 +223,7 @@ export const app = new Elysia() }); if (!identityRes.ok) { const errorBody = await identityRes.json().catch(() => null); - throw status(502, { error: errorBody ?? "Failed to fetch Discord identity" }); + return status(502, { error: errorBody ?? "Failed to fetch Discord identity" }); } const identityResPayload = await identityRes.json(); @@ -274,7 +282,7 @@ export const app = new Elysia() .post("/standards/config/switch", async ({ body: { standardsConfigId } }) => { await s.Calculator.Standards.Config.switch(standardsConfigId); }, { - body: t.Object({ standardsConfigId: t.String() }) + body: t.Object({ standardsConfigId: BigIntIdSchema }) }) ) ) @@ -282,7 +290,7 @@ export const app = new Elysia() .post("/configs", async ({ body: { name, datasetId, params } }) => { return await s.Standards.Configs.create(name, datasetId, params); }, { - body: t.Object({ name: t.String(), datasetId: t.String(), params: StandardsParamsSchema }) + body: t.Object({ name: t.String(), datasetId: BigIntIdSchema, params: StandardsParamsSchema }) }) .get("/configs", async () => { return await s.Standards.Configs.list(); @@ -290,29 +298,29 @@ export const app = new Elysia() .get("/configs/:id", async ({ params: { id } }) => { return await s.Standards.Configs.get(id); }, { - params: t.Object({ id: t.String() }) + params: t.Object({ id: BigIntIdSchema }) }) .put("/configs/:id", async ({ params: { id }, body: { name, datasetId, params: parameters } }) => { await s.Standards.Configs.update(id, name, datasetId, parameters); }, { - params: t.Object({ id: t.String() }), - body: t.Object({ name: t.String(), datasetId: t.String(), params: StandardsParamsSchema }) + params: t.Object({ id: BigIntIdSchema }), + body: t.Object({ name: t.String(), datasetId: BigIntIdSchema, params: StandardsParamsSchema }) }) .delete("/configs/:id", async ({ params: { id } }) => { await s.Standards.Configs.delete(id); - }, { params: t.Object({ id: t.String() }) }) + }, { params: t.Object({ id: BigIntIdSchema }) }) .get("/datasets", async () => { return await s.Standards.Datasets.list(); }) .get("/datasets/:id", async ({ params: { id } }) => { return await s.Standards.Datasets.get(id); }, { - params: t.Object({ id: t.String() }) + params: t.Object({ id: BigIntIdSchema }) }) .patch("/datasets/:id", async ({ params: { id }, body: { name } }) => { await s.Standards.Datasets.update(id, name); }, { - params: t.Object({ id: t.String() }), + params: t.Object({ id: BigIntIdSchema }), body: t.Object({ name: t.String() }) }) ) @@ -343,7 +351,7 @@ export const app = new Elysia() // Sync status .get("/", async ({ sessionId }) => { const latestSyncState = await s.Commerce.Apparel.Syncs.getLatestSyncState(sessionId); - if (!latestSyncState) throw new NotFoundError("No product sync found for the provided session"); + if (!latestSyncState) return status(404, { error: "No product sync found for the provided session" }); return latestSyncState; }) // Run sync @@ -362,10 +370,10 @@ export const app = new Elysia() ) .get("/:pProductId", async ({ params: { pProductId } }) => { const pProduct = await s.Commerce.Printful.Products.get(pProductId); - if (!pProduct) throw new NotFoundError("Missing printful product"); + if (!pProduct) return status(404, { error: "Missing printful product" }); const wProductId = pProduct.sync_product.external_id.split("-")[0]; - if (!wProductId) throw new NotFoundError("Missing webflow product ID"); + if (!wProductId) return status(404, { error: "Missing webflow product ID" }); const wProduct = await s.Commerce.Webflow.Products.get(wProductId); @@ -398,7 +406,7 @@ export const app = new Elysia() }) .get("/:wOrderId", async ({ params: { wOrderId } }) => { const wOrder = await s.Commerce.Webflow.Orders.get(wOrderId); - if (!wOrder) throw new NotFoundError("Missing webflow order"); + if (!wOrder) return status(404, { error: "Missing webflow order" }); const pOrder = await s.Commerce.Printful.Orders.get(`@${wOrder.orderId}`); @@ -406,10 +414,10 @@ export const app = new Elysia() }, { params: t.Object({ wOrderId: t.String() }) }) .post("/sync/:wOrderId", async ({ params: { wOrderId } }) => { const wOrder = await s.Commerce.Webflow.Orders.get(wOrderId); - if (!wOrder) throw new NotFoundError("Missing webflow order"); + if (!wOrder) return status(404, { error: "Missing webflow order" }); const pOrder = await s.Commerce.Printful.Orders.get(`@${wOrder.orderId}`); - if (pOrder) throw status(409, { error: "Cannot sync an already synced webflow order" }); + if (pOrder) return status(409, { error: "Cannot sync an already synced webflow order" }); await s.Commerce.Apparel.Orders.Queue.enqueue({ type: "apparel_order_create", @@ -441,16 +449,16 @@ export const app = new Elysia() }) .get("/:id", async ({ params: { id } }) => { const event = await s.Events.get(id); - if (!event) throw new NotFoundError("Event not found"); + if (!event) return status(404, { error: "Event not found" }); return { ...event, status: EventsService.status(event) }; }, { - params: t.Object({ id: t.String() }) + params: t.Object({ id: BigIntIdSchema }) }) .post("/:id/retry", async ({ params: { id } }) => { const retried = await s.Events.retry(id); - if (!retried) throw new NotFoundError("Event not found or not in a failed state"); + if (!retried) return status(404, { error: "Event not found or not in a failed state" }); }, { - params: t.Object({ id: t.String() }) + params: t.Object({ id: BigIntIdSchema }) }) .get("/groups", async ({ }) => queues.map((q) => q.group)) ) @@ -460,7 +468,7 @@ export const app = new Elysia() // TODO: for added security, could enforce bot or admin only access .get("/:id/stats", async ({ params: { id } }) => { const stats = await s.Accounts.stats(id); - if (!stats) throw new NotFoundError("Account stats not found"); + if (!stats) return status(404, { error: "Account stats not found" }); return stats; }, { params: t.Object({ id: t.String() }) @@ -468,7 +476,7 @@ export const app = new Elysia() .guard({ authAdmin: true }, (app) => app .get("/:id", async ({ params: { id } }) => { const account = await s.Accounts.get(id); - if (!account) throw new NotFoundError("Account not found"); + if (!account) return status(404, { error: "Account not found" }); return account; }, { params: t.Object({ id: t.String() }) @@ -477,7 +485,7 @@ export const app = new Elysia() .group("/me", { auth: true }, (app) => app .get("/stats", async ({ accountId }) => { const stats = await s.Accounts.stats(accountId); - if (!stats) throw new NotFoundError("Account stats not found"); + if (!stats) return status(404, { error: "Account stats not found" }); return stats; }) .post("/assessments", async ({ body: { player, activityPerformances }, accountId }) => { @@ -494,55 +502,48 @@ export const app = new Elysia() }) .delete("/assessments/:id", async ({ params: { id }, accountId }) => { const deleted = await s.Assessments.delete(id, accountId); - if (!deleted) throw new NotFoundError("Assessment not found"); + if (!deleted) return status(404, { error: "Assessment not found" }); }, { - params: t.Object({ id: t.String() }) + params: t.Object({ id: BigIntIdSchema }) }) .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"); + const assessment = await s.Assessments.get(assessmentId, accountId); + if (!assessment) return status(404, { error: "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" }); + if (!created) return status(409, { error: "This assessment already has a verification" }); return created; }, { body: t.Object({ - assessmentId: t.String(), + assessmentId: BigIntIdSchema, 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"); + const verification = await s.Verifications.get(id, accountId); + if (!verification) return status(404, { error: "Verification not found" }); if (assessmentId === undefined && Object.keys(activityMediaKeys ?? {}).length === 0) - throw status(400, { error: "Nothing to update" }); + return 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"); - } + if (assessmentId !== undefined && !await s.Assessments.get(assessmentId, accountId)) + return status(404, { error: "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` }); + const updated = await s.Verifications.update(id, { assessmentId, activityMediaKeys }); + if (!updated) return status(409, { error: `Cannot update a ${verification.status} verification` }); return updated; }, { - params: t.Object({ id: t.String() }), + params: t.Object({ id: BigIntIdSchema }), body: t.Object({ - assessmentId: t.Optional(t.String()), + assessmentId: t.Optional(BigIntIdSchema), 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"); + if (!await s.Verifications.get(id, accountId)) return status(404, { error: "Verification not found" }); const submitted = await s.Verifications.submit(id); if (submitted) { @@ -550,18 +551,18 @@ export const app = new Elysia() return submitted; } - const current = await s.Verifications.get(id); - if (!current) throw new NotFoundError("Verification not found"); + const current = await s.Verifications.get(id, accountId); + if (!current) return status(404, { error: "Verification not found" }); if (current.status === "submitted") return { id: current.id, status: current.status }; const missingActivityMediaKeys = VerificationsService.missingActivityMediaKeys(current); - throw status(409, { + return 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() }) + params: t.Object({ id: BigIntIdSchema }) }) ) ) @@ -569,7 +570,7 @@ export const app = new Elysia() // ASSESSMENTS .group("/assessments", { authAdmin: true }, (app) => app .post("/", async ({ body: { player, activityPerformances, id } }) => { - await s.Assessments.create(player, activityPerformances, id); + return await s.Assessments.create(player, activityPerformances, id); }, { body: t.Object({ player: PlayerSchema, @@ -579,9 +580,10 @@ export const app = new Elysia() }) .put("/:id", async ({ body: { player, activityPerformances }, params: { id } }) => { const updated = await s.Assessments.update(id, player, activityPerformances); - if (!updated) throw new NotFoundError("Assessment not found"); + if (!updated) return status(404, { error: "Assessment not found" }); + return updated; }, { - params: t.Object({ id: t.String() }), + params: t.Object({ id: BigIntIdSchema }), body: t.Object({ player: PlayerSchema, activityPerformances: t.Array(ActivityPerformanceSchema), @@ -606,20 +608,20 @@ export const app = new Elysia() }) .get("/:id", async ({ params: { id } }) => { const verification = await s.Verifications.get(id); - if (!verification) throw new NotFoundError("Verification not found"); + if (!verification) return status(404, { error: "Verification not found" }); return verification; }, { - params: t.Object({ id: t.String() }) + params: t.Object({ id: BigIntIdSchema }) }) .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"); - throw status(409, { error: `Cannot request action on a ${verification.status} verification` }); + if (!verification) return status(404, { error: "Verification not found" }); + return status(409, { error: `Cannot request action on a ${verification.status} verification` }); }, { - params: t.Object({ id: t.String() }), + params: t.Object({ id: BigIntIdSchema }), body: t.Object({ reviewerNotes: t.Optional(t.String()), activityVerifications: ActivityVerificationsSchema, @@ -630,10 +632,10 @@ export const app = new Elysia() if (updated) return updated; const verification = await s.Verifications.get(id); - if (!verification) throw new NotFoundError("Verification not found"); - throw status(409, { error: `Cannot complete a ${verification.status} verification` }); + if (!verification) return status(404, { error: "Verification not found" }); + return status(409, { error: `Cannot complete a ${verification.status} verification` }); }, { - params: t.Object({ id: t.String() }), + params: t.Object({ id: BigIntIdSchema }), body: t.Object({ activityVerifications: ActivityVerificationsSchema, }) @@ -644,7 +646,7 @@ export const app = new Elysia() .post("/webhooks/printful", async ({ body, query }) => { // https://webflow.com/integrations/printful if (!s.Commerce.Printful.Util.verifySecret(query.secret)) - throw status(400, "Invalid secret"); + return status(400, { error: "Invalid secret" }); const payload = body as Printful.Webhook.EventPayload; @@ -667,7 +669,7 @@ export const app = new Elysia() const pProduct = payload.data.sync_product; const wProductId = pProduct.external_id.split("-")[0]; log.info({ externalId: payload.data.sync_product.external_id, wProductId }, "printful webhook: product deleted"); - if (!wProductId) throw new NotFoundError("Missing webflow product ID"); + if (!wProductId) return status(404, { error: "Missing webflow product ID" }); await s.Commerce.Apparel.Syncs.Queue.enqueue({ type: "apparel_sync_delete", @@ -699,7 +701,7 @@ export const app = new Elysia() }, { query: t.Object({ secret: t.String() }) }) .post("/webhooks/webflow", async ({ request, body }) => { if (!s.Commerce.Webflow.Util.verifySecret(request, body)) - throw status(400, "Invalid signature"); + return status(400, { error: "Invalid signature" }); const payload = body as Webflow.Webhook.EventPayload; diff --git a/apps/api/src/services/assessments.ts b/apps/api/src/services/assessments.ts index c651057..f8c996c 100644 --- a/apps/api/src/services/assessments.ts +++ b/apps/api/src/services/assessments.ts @@ -24,10 +24,11 @@ export class AssessmentsService { .executeTakeFirstOrThrow(); } - async get(id: string) { + async get(id: string, accountId?: string) { return await db.selectFrom("assessments") .selectAll() .where("id", "=", id) + .$if(accountId !== undefined, (qb) => qb.where("account_id", "=", accountId!)) .executeTakeFirst(); } @@ -48,7 +49,7 @@ export class AssessmentsService { } async update(id: string, player: Player, activityPerformances: ActivityPerformance[]) { - const result = await db.updateTable("assessments") + return await db.updateTable("assessments") .where("id", "=", id) .set({ name: player.name ?? "Anonymous", @@ -62,8 +63,8 @@ export class AssessmentsService { perf_run: AssessmentsService.performanceFor(activityPerformances, Activity.Run), perf_cone_drill: AssessmentsService.performanceFor(activityPerformances, Activity.ConeDrill), }) + .returning("id") .executeTakeFirst(); - return result.numUpdatedRows > 0n; } async delete(id: string, accountId: string): Promise { diff --git a/apps/api/src/services/verifications.ts b/apps/api/src/services/verifications.ts index f6585bd..9363c47 100644 --- a/apps/api/src/services/verifications.ts +++ b/apps/api/src/services/verifications.ts @@ -14,7 +14,7 @@ export const VerificationStatusSchema = t.Union([ ]); export type VerificationStatus = Static; -export const REQUIRED_MEDIA_ACTIVITIES = Object.values(Activity); +const REQUIRED_MEDIA_ACTIVITIES = Object.values(Activity); const MEDIA_KEY_COLUMN = { [Activity.BackSquat]: "media_key_back_squat", @@ -80,9 +80,10 @@ export class VerificationsService { .executeTakeFirst(); } - async get(id: string) { + async get(id: string, accountId?: string) { return await VerificationsService.baseQuery() .where("verifications.id", "=", id) + .$if(accountId !== undefined, (qb) => qb.where("assessments.account_id", "=", accountId!)) .executeTakeFirst(); } @@ -173,7 +174,6 @@ export class VerificationsService { "verifications.verf_broad_jump", "verifications.verf_run", "verifications.verf_cone_drill", - "assessments.account_id", "assessments.name", "assessments.gender", "assessments.age", diff --git a/apps/api/src/util.ts b/apps/api/src/util.ts index db66c0e..5118981 100644 --- a/apps/api/src/util.ts +++ b/apps/api/src/util.ts @@ -2,6 +2,7 @@ import cluster from 'node:cluster'; import { createHash } from 'node:crypto'; import { pino } from 'pino'; import os from 'node:os' +import { t } from 'elysia'; export const log = pino({ level: Bun.env.LOG_LEVEL ?? "info", @@ -37,6 +38,8 @@ export const WORKER_COUNT = Math.min(os.availableParallelism(), +env.MAX_WORKER_ export const DEFAULT_NAME = "Default"; export const DUMMY_PASSWORD_HASH = await Bun.password.hash("Dummy"); +export const BigIntIdSchema = t.String({ pattern: "^\\d+$" }); + function requireEnv(key: string): string { const val = Bun.env[key]; if (!val) {