From e96c40c9c7728ccc93d2547b6ff169a60e49bfa1 Mon Sep 17 00:00:00 2001 From: Dominic Ferrando Date: Sat, 15 Aug 2026 14:53:36 -0400 Subject: [PATCH] Implement basic service and api for accounts --- .../api/src/database/migrations/2026-06-14.ts | 16 ++-- apps/api/src/server.ts | 27 ++++++- apps/api/src/services/accounts.ts | 73 +++++++++++++++++++ 3 files changed, 109 insertions(+), 7 deletions(-) create mode 100644 apps/api/src/services/accounts.ts diff --git a/apps/api/src/database/migrations/2026-06-14.ts b/apps/api/src/database/migrations/2026-06-14.ts index ecd2304..c93f2c6 100644 --- a/apps/api/src/database/migrations/2026-06-14.ts +++ b/apps/api/src/database/migrations/2026-06-14.ts @@ -11,18 +11,24 @@ export async function up(db: Kysely): Promise { ) .addColumn("discord_id", "text", (cb) => cb.unique()) .addColumn("email", "text", (cb) => cb.unique()) - .addColumn("name", "text", (cb) => cb.notNull().defaultTo("")) - .addColumn("gender", "text", (cb) => cb.notNull()) + .addColumn("name", "text", (cb) => cb.notNull().defaultTo("Anonymous")) + .addColumn("gender", "text") .execute(); // TABLE: ASSESSMENTS await db.schema.createTable("assessments") .$call(addDefaultColumns) .addColumn("account_id", "uuid") - .addColumn("name", "text", (cb) => cb.notNull().defaultTo("")) + .addColumn("name", "text", (cb) => cb.notNull().defaultTo("Anonymous")) .addColumn("gender", "text", (cb) => cb.notNull()) - .addColumn("age", "numeric", (cb) => cb.notNull()) - .addColumn("weight", "numeric", (cb) => cb.notNull()) + .addColumn("age", "integer", (cb) => cb.notNull()) + .addColumn("weight", "float8", (cb) => cb.notNull()) + .addColumn("perf_back_squat", "float8") // kg + .addColumn("perf_deadlift", "float8") // kg + .addColumn("perf_bench_press", "float8") // kg + .addColumn("perf_run", "float8") // ms + .addColumn("perf_broad_jump", "float8") // cm + .addColumn("perf_cone_drill", "float8") // ms .addForeignKeyConstraint( "fk_assessments_account_id", ["account_id"], diff --git a/apps/api/src/server.ts b/apps/api/src/server.ts index 5813c59..d7c318a 100644 --- a/apps/api/src/server.ts +++ b/apps/api/src/server.ts @@ -20,10 +20,11 @@ import { CalculatorService, CalculatorUnavailableError } from "./services/calcul import { StandardsParamsSchema } from "@blade-and-brawn/calculator"; import { StandardsService } from "./services/standards"; import { EventsService, EventStatusSchema } from "./services/events"; +import { AccountsService } from "./services/accounts"; // CONSTANTS // ----------------------- -const EVENT_QUEUE_MANAGE_DELAY_MS = 1000 // 1 sec +const EVENT_QUEUE_MANAGE_DELAY_MS = 1000 // 1 sec const JWT_EXP = "1d"; // SERVICES @@ -32,8 +33,9 @@ const s = (() => { const Standards = new StandardsService(); const Calculator = new CalculatorService(DEFAULT_NAME); const Commerce = new CommerceService(); + const Accounts = new AccountsService(Calculator); const Events = new EventsService(); - return { Standards, Calculator, Commerce, Events }; + return { Standards, Calculator, Commerce, Accounts, Events }; })(); // QUEUES @@ -337,6 +339,27 @@ export const app = new Elysia() .get("/groups", async ({ }) => queues.map((q) => q.group)) ) + // ACCOUNTS + .group("/accounts", (app) => app + // Non-authenticated + .get("/:id/stats", async ({ params: { id } }) => { + const stats = await s.Accounts.stats(id); + if (!stats) throw new NotFoundError("Account stats not found"); + return stats; + }, { + params: t.Object({ id: t.String() }) + }) + // Authenticated + .guard({ auth: true }) + .get("/:id", async ({ params: { id } }) => { + const account = await s.Accounts.get(id); + if (!account) throw new NotFoundError("Account not found"); + return account; + }, { + params: t.Object({ id: t.String() }) + }) + ) + // WEBHOOKS .post("/webhooks/printful", async ({ body, query }) => { // https://webflow.com/integrations/printful diff --git a/apps/api/src/services/accounts.ts b/apps/api/src/services/accounts.ts new file mode 100644 index 0000000..8cb2c6e --- /dev/null +++ b/apps/api/src/services/accounts.ts @@ -0,0 +1,73 @@ +import { Activity, Gender, type Player } from "@blade-and-brawn/domain"; +import { db } from "../database/db"; +import type { CalculatorService } from "./calculator"; +import { Value } from "@sinclair/typebox/value"; +import { t } from "elysia"; + +export class AccountsService { + constructor(private Calculator: CalculatorService) { } + + async get(id: string) { + return await (db).selectFrom("accounts") + .selectAll() + .where("id", "=", id) + .executeTakeFirst(); + } + + async stats(id: string) { + const latestAssessment = await db.selectFrom("accounts") + .innerJoin("assessments", "assessments.account_id", "accounts.id") + .select([ + "assessments.age", "assessments.gender", "assessments.weight", "assessments.name", + "assessments.perf_back_squat", "assessments.perf_bench_press", "assessments.perf_broad_jump", + "assessments.perf_cone_drill", "assessments.perf_deadlift", "assessments.perf_run" + ]) + .where("accounts.id", "=", id) + .orderBy("assessments.created_at", "desc") + .limit(1) + .executeTakeFirst(); + if (!latestAssessment) return; + Value.Assert(t.Enum(Gender), latestAssessment.gender); + + return this.Calculator.calculate( + { + name: latestAssessment.name, + metrics: { + age: latestAssessment.age, + weight: latestAssessment.weight, + gender: latestAssessment.gender + } + }, + [ + // Strength + { + activity: Activity.BenchPress, + performance: latestAssessment.perf_bench_press ?? 0 + }, + { + activity: Activity.Deadlift, + performance: latestAssessment.perf_deadlift ?? 0 + }, + { + activity: Activity.BackSquat, + performance: latestAssessment.perf_back_squat ?? 0 + }, + // Power + { + activity: Activity.BroadJump, + performance: latestAssessment.perf_broad_jump ?? 0 + }, + // Endurance + { + activity: Activity.Run, + performance: latestAssessment.perf_run ?? 0 + }, + // Agility + { + activity: Activity.ConeDrill, + performance: latestAssessment.perf_cone_drill ?? 0 + }, + ] + ); + } +}