diff --git a/apps/api/README.md b/apps/api/README.md index b0f3ca5..fe4bf14 100644 --- a/apps/api/README.md +++ b/apps/api/README.md @@ -57,3 +57,23 @@ this directory: Deployed to Fly.io (`fly.toml`). After a domain changes, re-run `src/scripts/register-webhooks.ts` with production env vars to point Printful's and Webflow's webhooks at the new domain — see issue #3 for the full checklist. + +### Running migrations/seed against production + +The deployed image only contains the compiled binary (see `Dockerfile`) — no +source, no `bun_modules`, no migration files — so these can't be run from +`fly ssh console` on the API app itself. Instead, use `migrate.sh` at the repo +root, which tunnels to the Postgres app (`blade-and-brawn-db`, legacy/unmanaged +Fly Postgres) via `fly proxy`, fetches the production `DATABASE_URL` for you, +and runs the scripts against it: + +```bash +./migrate.sh migrate # db:migrate:latest (default if no argument given) +./migrate.sh seed # db:seed +./migrate.sh both # both, in order +``` + +Both underlying scripts prompt for a `y/N` confirmation before touching the +database, and `db:seed` is idempotent (skips seeding if the default rows +already exist). If the tunneled connection fails on TLS, legacy Postgres +sometimes needs `?sslmode=disable` appended — edit `migrate.sh` if so. diff --git a/apps/api/package.json b/apps/api/package.json index d685343..592fd06 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -27,11 +27,11 @@ "@elysia/jwt": "^1.4.2", "@elysia/server-timing": "^1.4.1", "@elysiajs/cors": "^1.4.2", - "@types/pg": "^8.20.0", + "@types/pg": "^8.23.1", "elysia": "^1.4.29", - "kysely": "^0.29.4", + "kysely": "^0.29.5", "ml-levenberg-marquardt": "^5.1.0", - "pg": "^8.22.0", + "pg": "^8.23.0", "zipcodes-us": "^1.1.3" }, "devDependencies": { diff --git a/apps/api/src/database/migrations/2026-06-14.ts b/apps/api/src/database/migrations/2026-06-14.ts new file mode 100644 index 0000000..4e011e4 --- /dev/null +++ b/apps/api/src/database/migrations/2026-06-14.ts @@ -0,0 +1,49 @@ +import { Kysely, sql } from 'kysely' +import { addDefaultColumns } from '../db'; + +export async function up(db: Kysely): Promise { + // TABLE: ACCOUNTS + await db.schema.createTable("accounts") + .addColumn("id", "uuid", (cb) => cb.primaryKey().defaultTo(sql`uuidv7()`)) + .addColumn("created_at", "timestamptz", (cb) => cb + .notNull() + .defaultTo(sql`now()`) + ) + .addColumn("discord_id", "text", (cb) => cb.unique()) + .addColumn("email", "text", (cb) => cb.unique()) + .addColumn("password_hash", "text") + .addColumn("role", "text", (cb) => cb.notNull().defaultTo("user")) + .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("Anonymous")) + .addColumn("gender", "text", (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"], + "accounts", + ["id"], + (cb) => cb.onDelete("cascade") + ) + .execute(); +} + +export async function down(db: Kysely): Promise { + // TABLE: ASSESSMENTS + await db.schema.dropTable("assessments").ifExists().execute() + // TABLE: ACCOUNTS + await db.schema.dropTable("accounts").ifExists().execute() +} diff --git a/apps/api/src/database/seed.ts b/apps/api/src/database/seed.ts index cf0e6d6..2ff759c 100644 --- a/apps/api/src/database/seed.ts +++ b/apps/api/src/database/seed.ts @@ -5,6 +5,7 @@ import { Value } from "@sinclair/typebox/value"; import { db } from "./db"; import { DEFAULT_NAME, env, log } from "../util"; import standardsConfig from "./seed-data/standards-config.json" with {type: "json"}; +import type { AccountRole } from "../services/accounts"; Value.Assert(StandardsConfigSchema, standardsConfig); @@ -68,6 +69,28 @@ async function seedCalculator(standardsConfigId: string): Promise { log.info({ id: result.id }, "seeded default calculator"); } +async function seedAdminAccount(): Promise { + const existing = await db.selectFrom("accounts") + .select(["id"]) + .where("email", "=", env.PUBLIC_ADMIN_EMAIL) + .executeTakeFirst(); + if (existing) { + log.info({ id: existing.id }, "admin account already seeded, skipping"); + return; + } + + const account = await db.insertInto("accounts") + .values({ + name: "Admin", + email: env.PUBLIC_ADMIN_EMAIL, + role: "admin" satisfies AccountRole, + password_hash: env.ADMIN_PASSWORD_HASH + }) + .returning("id") + .executeTakeFirstOrThrow(); + log.info({ id: account.id }, "seeded admin account"); +} + (async () => { const answer = prompt(`Seed the database (${env.DATABASE_URL}) with default standards data? (y/N)`); if (answer?.trim().toLowerCase() !== "y") { @@ -80,6 +103,7 @@ async function seedCalculator(standardsConfigId: string): Promise { const datasetId = await seedStandardsDataset(); const standardsConfigId = await seedStandardsConfig(datasetId); await seedCalculator(standardsConfigId); + await seedAdminAccount(); log.info("seed finished"); } catch (err) { diff --git a/apps/api/src/scripts/register-webhooks.ts b/apps/api/src/scripts/register-webhooks.ts index 675bb75..dda5593 100644 --- a/apps/api/src/scripts/register-webhooks.ts +++ b/apps/api/src/scripts/register-webhooks.ts @@ -2,7 +2,7 @@ import { Printful, PrintfulClient, PrintfulError, Webflow, WebflowClient, Webflo import { env } from "../util"; const DOMAIN = env.NODE_ENV === "development" ? - "dev.api.bladeandbrawn.com" : + "dev-api.bladeandbrawn.com" : "api.bladeandbrawn.com"; const PRINTFUL_WEBHOOK_URL = env.NODE_ENV === "development" ? diff --git a/apps/api/src/server.ts b/apps/api/src/server.ts index 5813c59..3bb8d25 100644 --- a/apps/api/src/server.ts +++ b/apps/api/src/server.ts @@ -3,14 +3,14 @@ import { PlayerSchema, } from "@blade-and-brawn/domain" import { cors } from "@elysiajs/cors"; -import { Elysia, NotFoundError, status, t } from "elysia"; +import { Elysia, NotFoundError, redirect, status, t } from "elysia"; import { PrintfulError, WebflowError, Printful, Webflow, } from "@blade-and-brawn/commerce"; -import { DEFAULT_NAME, env, log, sha256Sum } from "./util"; +import { 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"; @@ -20,11 +20,16 @@ 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, type AccountRole } from "./services/accounts"; +import { Value } from "@sinclair/typebox/value"; +import { AssessmentsService } from "./services/assessments"; +import { Not } from "@sinclair/typebox"; // CONSTANTS // ----------------------- -const EVENT_QUEUE_MANAGE_DELAY_MS = 1000 // 1 sec +const EVENT_QUEUE_MANAGE_DELAY_MS = 1000 // 1 sec const JWT_EXP = "1d"; +const JWT_EXP_SECONDS = 60 * 60 * 24; // keep in sync with JWT_EXP; used for cookie maxAge // SERVICES // ----------------------- @@ -32,8 +37,10 @@ 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 }; + const Assessments = new AssessmentsService(); + return { Standards, Calculator, Commerce, Accounts, Events, Assessments }; })(); // QUEUES @@ -49,11 +56,18 @@ const authPlugin = new Elysia({ name: "auth" }) .use(jwt({ name: "jwt", secret: env.AUTH_SECRET })) .guard({ cookie: t.Cookie({ auth: t.Optional(t.String()) }) }) .macro({ + 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"); + 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.sessionId) throw status(401, "Unauthorized"); - return { sessionId: token.sessionId.toString() }; + if (!token || !token.role || !token.accountId || !token.sessionId) throw status(401, "Unauthorized"); + return { role: token.role.toString(), sessionId: token.sessionId.toString(), accountId: token.accountId.toString() }; } } }); @@ -74,7 +88,12 @@ export const app = new Elysia() ], }), ) - .guard({ cookie: t.Cookie({ auth: t.Optional(t.String()) }) }) + .guard({ + cookie: t.Cookie({ + auth: t.Optional(t.String()), + authDiscord: t.Optional(t.String()) + }) + }) .use(authPlugin) .error({ @@ -121,22 +140,117 @@ export const app = new Elysia() .get("/health", () => ({ status: "ok" })) // AUTHENTICATION - .post("/auth/login", async ({ jwt, body, cookie: { auth } }) => { - const match = crypto.timingSafeEqual(sha256Sum(body.password), Buffer.from(env.ADMIN_PASSWORD, "hex")); - if (!match) throw status(401, "Invalid credentials"); + .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"); auth.set({ - value: await jwt.sign({ sessionId: randomUUIDv7(), exp: JWT_EXP }), + value: await jwt.sign({ role: account.role, sessionId: randomUUIDv7(), accountId: account.id, exp: JWT_EXP }), path: "/", - maxAge: 60 * 60 * 24 * 7, - sameSite: "lax", + maxAge: JWT_EXP_SECONDS, + sameSite: env.NODE_ENV === "production" ? "lax" : "none", httpOnly: true, - secure: env.NODE_ENV === "production", + secure: true, domain: env.NODE_ENV === "production" ? ".bladeandbrawn.com" : undefined }); - }, { body: t.Object({ password: t.String() }) }) + }, { body: t.Object({ email: t.String(), password: t.String() }) }) + + .group("/auth/discord", (app) => app + .get("/login", async ({ cookie: { authDiscord } }) => { + const state = crypto.randomUUID(); + + authDiscord.set({ + value: state, + path: "/", + maxAge: 60 * 10, // 10 min + sameSite: "lax", + httpOnly: true, + secure: env.NODE_ENV === "production" + }); + + const url = new URL("https://discord.com/api/oauth2/authorize"); + url.searchParams.set("client_id", env.BOT_CLIENT_ID); + url.searchParams.set("redirect_uri", env.BOT_REDIRECT_URL); + url.searchParams.set("response_type", "code"); + url.searchParams.set("scope", "identify email"); + url.searchParams.set("state", state); + + 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.state) throw status(400, "No Discord OAuth2 state query parameter provided"); + if (query.state !== authDiscord.value) throw status(400, "Invalid Discord OAuth state"); + + const tokenRes = await fetch("https://discord.com/api/oauth2/token", { + method: "POST", + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body: new URLSearchParams({ + client_id: env.BOT_CLIENT_ID, + client_secret: env.BOT_CLIENT_SECRET, + grant_type: "authorization_code", + code: query.code ?? "", + redirect_uri: env.BOT_REDIRECT_URL + }).toString() + }); + if (!tokenRes.ok) { + const errorBody = await tokenRes.json().catch(() => null); + throw status(502, { error: errorBody ?? "Discord token exchange failed" }); + } + + const tokenResPayload = await tokenRes.json(); + Value.Assert(t.Object({ access_token: t.String() }), tokenResPayload); + + const identityRes = await fetch("https://discord.com/api/users/@me", { + headers: { "Authorization": `Bearer ${tokenResPayload.access_token}` } + }); + if (!identityRes.ok) { + const errorBody = await identityRes.json().catch(() => null); + throw status(502, { error: errorBody ?? "Failed to fetch Discord identity" }); + } + + const identityResPayload = await identityRes.json(); + Value.Assert(t.Object({ id: t.String(), email: t.Optional(t.String()), username: t.String() }), identityResPayload); + + // Create the account + let accountId = (await s.Accounts.get(`@${identityResPayload.id}`))?.id; + if (!accountId) { + accountId = (await s.Accounts.create( + identityResPayload.id, + identityResPayload.email, + identityResPayload.username + )).id; + } + + // Authenticate + auth.set({ + value: await jwt.sign({ role: "user" satisfies AccountRole, sessionId: randomUUIDv7(), accountId, exp: JWT_EXP }), + path: "/", + maxAge: JWT_EXP_SECONDS, + sameSite: env.NODE_ENV === "production" ? "lax" : "none", + httpOnly: true, + secure: true, + domain: env.NODE_ENV === "production" ? + ".bladeandbrawn.com" : + undefined + }); + + return redirect(env.BOT_LOGIN_REDIRECT_URL, 302); + }, { + query: t.Object({ + code: t.Optional(t.String()), + state: t.Optional(t.String()), + error: t.Optional(t.String()), + error_description: t.Optional(t.String()), + }) + }) + ) // CALCULATOR .group("/calculator", (app) => app @@ -150,17 +264,18 @@ export const app = new Elysia() }), }) // Authenticated - .guard({ auth: true }) - .get("/standards/config", async () => { - return await s.Calculator.Standards.Config.get(); - }) - .post("/standards/config/switch", async ({ body: { standardsConfigId } }) => { - await s.Calculator.Standards.Config.switch(standardsConfigId); - }, { - body: t.Object({ standardsConfigId: t.String() }) - }) + .guard({ authAdmin: true }, (app) => app + .get("/standards/config", async () => { + return await s.Calculator.Standards.Config.get(); + }) + .post("/standards/config/switch", async ({ body: { standardsConfigId } }) => { + await s.Calculator.Standards.Config.switch(standardsConfigId); + }, { + body: t.Object({ standardsConfigId: t.String() }) + }) + ) ) - .group("/standards", { auth: true }, (app) => app + .group("/standards", { authAdmin: true }, (app) => app .post("/configs", async ({ body: { name, datasetId, params } }) => { return await s.Standards.Configs.create(name, datasetId, params); }, { @@ -200,7 +315,7 @@ export const app = new Elysia() ) // COMMERCE - .group("/commerce", { auth: true }, (app) => app + .group("/commerce", { authAdmin: true }, (app) => app .group("/products", (app) => app .get("/", async ({ query }) => { const [pProducts, wProducts] = await Promise.all([ @@ -304,7 +419,7 @@ export const app = new Elysia() ) // EVENTS - .group("/events", { auth: true }, (app) => app + .group("/events", { authAdmin: true }, (app) => app .get("/", async ({ query }) => { const events = await s.Events.list({ filter: { status: query.status, type: query.type, group: query.group }, @@ -337,6 +452,77 @@ export const app = new Elysia() .get("/groups", async ({ }) => queues.map((q) => q.group)) ) + // ACCOUNTS + .group("/accounts", (app) => app + .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() }) + }) + .guard({ auth: true }, (app) => app + .get("/me/stats", async ({ accountId }) => { + const stats = await s.Accounts.stats(accountId); + if (!stats) throw new NotFoundError("Account stats not found"); + return stats; + }) + ) + .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"); + return account; + }, { + params: t.Object({ id: t.String() }) + }) + ) + ) + + // ASSESSMENTS + .group("/assessments", (app) => app + .guard({ auth: true }, (app) => app + .post("/me", async ({ body: { player, activityPerformances }, accountId }) => { + return await s.Assessments.create(player, activityPerformances, accountId); + }, { + body: t.Object({ + player: PlayerSchema, + activityPerformances: t.Array(ActivityPerformanceSchema) + }) + }) + .get("/me", async ({ accountId }) => { + return await s.Assessments.list({ filter: { accountId } }); + }) + .delete("/me/:id", async ({ params: { id }, accountId }) => { + const deleted = await s.Assessments.delete(id, accountId); + if (!deleted) throw new NotFoundError("Assessment not found"); + }, { + params: t.Object({ id: t.String() }) + }) + ) + .guard({ authAdmin: true }, (app) => app + .post("/", async ({ body: { player, activityPerformances, id } }) => { + await s.Assessments.create(player, activityPerformances, id); + }, { + body: t.Object({ + player: PlayerSchema, + activityPerformances: t.Array(ActivityPerformanceSchema), + id: t.Optional(t.String()), + }) + }) + .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"); + }, { + params: t.Object({ id: t.String() }), + body: t.Object({ + player: PlayerSchema, + activityPerformances: t.Array(ActivityPerformanceSchema), + }) + }) + ) + ) + // 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..dab83fd --- /dev/null +++ b/apps/api/src/services/accounts.ts @@ -0,0 +1,99 @@ +import { Activity, Gender } 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 type AccountRole = "user" | "admin"; + +export class AccountsService { + constructor(private Calculator: CalculatorService) { } + + private static idComparison(id: string): ["accounts.id" | "accounts.discord_id", "=", string] { + return id.startsWith("@") + ? ["accounts.discord_id", "=", id.slice(1)] + : ["accounts.id", "=", id]; + } + + async create(discordId: string, email?: string, name?: string) { + return await db.insertInto("accounts") + .values({ + discord_id: discordId, + name: name ?? "", + email: email ?? null + }) + .returning("id") + .executeTakeFirstOrThrow(); + } + + async get(id: string) { + return await (db).selectFrom("accounts") + .select(["id", "discord_id", "name", "email", "gender", "role"]) + .where(...AccountsService.idComparison(id)) + .executeTakeFirst(); + } + + async getByEmail(email: string) { + return await (db).selectFrom("accounts") + .select(["id", "discord_id", "name", "email", "gender", "password_hash", "role"]) + .where("email", "=", email) + .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(...AccountsService.idComparison(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 + }, + ] + ); + } +} diff --git a/apps/api/src/services/assessments.ts b/apps/api/src/services/assessments.ts new file mode 100644 index 0000000..d6b2572 --- /dev/null +++ b/apps/api/src/services/assessments.ts @@ -0,0 +1,69 @@ +import { Activity, type ActivityPerformance, type Player } from "@blade-and-brawn/domain"; +import { db } from "../database/db"; + +export class AssessmentsService { + private static performanceFor = (activityPerformances: ActivityPerformance[], activity: Activity) => + activityPerformances.find((p) => p.activity === activity)?.performance ?? null; + + async create(player: Player, activityPerformances: ActivityPerformance[], accountId?: string) { + return await db.insertInto("assessments") + .values({ + account_id: accountId ?? null, + name: player.name ?? "Anonymous", + age: player.metrics.age, + weight: player.metrics.weight, + gender: player.metrics.gender, + perf_back_squat: AssessmentsService.performanceFor(activityPerformances, Activity.BackSquat), + perf_deadlift: AssessmentsService.performanceFor(activityPerformances, Activity.Deadlift), + perf_bench_press: AssessmentsService.performanceFor(activityPerformances, Activity.BenchPress), + perf_broad_jump: AssessmentsService.performanceFor(activityPerformances, Activity.BroadJump), + perf_run: AssessmentsService.performanceFor(activityPerformances, Activity.Run), + perf_cone_drill: AssessmentsService.performanceFor(activityPerformances, Activity.ConeDrill), + }) + .returning("id") + .executeTakeFirstOrThrow(); + } + + async list(opt: { + filter?: { accountId?: string }, + limit?: number, + offset?: number, + } = {}) { + return await db.selectFrom("assessments") + .selectAll() + .$if(opt.filter?.accountId !== undefined, (qb) => qb + .where("account_id", "=", opt.filter!.accountId!) + ) + .orderBy("created_at", "desc") + .$if(opt.limit !== undefined, (qb) => qb.limit(opt.limit!)) + .$if(opt.offset !== undefined, (qb) => qb.offset(opt.offset!)) + .execute(); + } + + async update(id: string, player: Player, activityPerformances: ActivityPerformance[]) { + const result = await db.updateTable("assessments") + .where("id", "=", id) + .set({ + name: player.name ?? "Anonymous", + age: player.metrics.age, + weight: player.metrics.weight, + gender: player.metrics.gender, + perf_back_squat: AssessmentsService.performanceFor(activityPerformances, Activity.BackSquat), + perf_deadlift: AssessmentsService.performanceFor(activityPerformances, Activity.Deadlift), + perf_bench_press: AssessmentsService.performanceFor(activityPerformances, Activity.BenchPress), + perf_broad_jump: AssessmentsService.performanceFor(activityPerformances, Activity.BroadJump), + perf_run: AssessmentsService.performanceFor(activityPerformances, Activity.Run), + perf_cone_drill: AssessmentsService.performanceFor(activityPerformances, Activity.ConeDrill), + }) + .executeTakeFirst(); + return result.numUpdatedRows > 0n; + } + + async delete(id: string, accountId: string): Promise { + const result = await db.deleteFrom("assessments") + .where("id", "=", id) + .where("account_id", "=", accountId) + .executeTakeFirst(); + return result.numDeletedRows > 0n; + } +} diff --git a/apps/api/src/util.ts b/apps/api/src/util.ts index 4e50f7e..db66c0e 100644 --- a/apps/api/src/util.ts +++ b/apps/api/src/util.ts @@ -20,16 +20,22 @@ export const env = { WEBFLOW_AUTH: requireEnv("WEBFLOW_AUTH"), WEBFLOW_WEBHOOK_SECRET: requireEnv("WEBFLOW_WEBHOOK_SECRET"), AUTH_SECRET: requireEnv("AUTH_SECRET"), - ADMIN_PASSWORD: requireEnv("ADMIN_PASSWORD"), + PUBLIC_ADMIN_EMAIL: requireEnv("PUBLIC_ADMIN_EMAIL"), + ADMIN_PASSWORD_HASH: requireEnv("ADMIN_PASSWORD_HASH"), DATABASE_URL: requireEnv("DATABASE_URL"), DATABASE_POOL_MAX: requireEnv("DATABASE_POOL_MAX"), MAX_WORKER_COUNT: requireEnv("MAX_WORKER_COUNT"), NODE_ENV: optionEnv("NODE_ENV", "development"), LOG_LEVEL: optionEnv("LOG_LEVEL", "info"), + BOT_CLIENT_ID: requireEnv("BOT_CLIENT_ID"), + BOT_CLIENT_SECRET: requireEnv("BOT_CLIENT_SECRET"), + BOT_REDIRECT_URL: requireEnv("BOT_REDIRECT_URL"), + BOT_LOGIN_REDIRECT_URL: requireEnv("BOT_LOGIN_REDIRECT_URL"), }; export const WORKER_COUNT = Math.min(os.availableParallelism(), +env.MAX_WORKER_COUNT); export const DEFAULT_NAME = "Default"; +export const DUMMY_PASSWORD_HASH = await Bun.password.hash("Dummy"); function requireEnv(key: string): string { const val = Bun.env[key]; diff --git a/apps/bot/.gitignore b/apps/bot/.gitignore new file mode 100644 index 0000000..e69de29 diff --git a/apps/bot/CLAUDE.md b/apps/bot/CLAUDE.md new file mode 100644 index 0000000..764c1dd --- /dev/null +++ b/apps/bot/CLAUDE.md @@ -0,0 +1,106 @@ + +Default to using Bun instead of Node.js. + +- Use `bun ` instead of `node ` or `ts-node ` +- Use `bun test` instead of `jest` or `vitest` +- Use `bun build ` instead of `webpack` or `esbuild` +- Use `bun install` instead of `npm install` or `yarn install` or `pnpm install` +- Use `bun run + + +``` + +With the following `frontend.tsx`: + +```tsx#frontend.tsx +import React from "react"; +import { createRoot } from "react-dom/client"; + +// import .css files directly and it works +import './index.css'; + +const root = createRoot(document.body); + +export default function Frontend() { + return

Hello, world!

; +} + +root.render(); +``` + +Then, run index.ts + +```sh +bun --hot ./index.ts +``` + +For more information, read the Bun API docs in `node_modules/bun-types/docs/**.mdx`. diff --git a/apps/bot/README.md b/apps/bot/README.md new file mode 100644 index 0000000..c028a6e --- /dev/null +++ b/apps/bot/README.md @@ -0,0 +1,15 @@ +# bot + +To install dependencies: + +```bash +bun install +``` + +To run: + +```bash +bun run index.ts +``` + +This project was created using `bun init` in bun v1.3.14. [Bun](https://bun.com) is a fast all-in-one JavaScript runtime. diff --git a/apps/bot/package.json b/apps/bot/package.json new file mode 100644 index 0000000..affa188 --- /dev/null +++ b/apps/bot/package.json @@ -0,0 +1,17 @@ +{ + "name": "@blade-and-brawn/bot", + "module": "index.ts", + "type": "module", + "private": true, + "devDependencies": { + "@blade-and-brawn/api": "workspace:*" + }, + "peerDependencies": {}, + "dependencies": { + "@elysia/eden": "^1.4.10", + "discord.js": "^14.27.0" + }, + "scripts": { + "dev": "bun run src/index.ts" + } +} diff --git a/apps/bot/src/index.ts b/apps/bot/src/index.ts new file mode 100644 index 0000000..d258c63 --- /dev/null +++ b/apps/bot/src/index.ts @@ -0,0 +1,32 @@ +import { Client, Events, GatewayIntentBits } from 'discord.js'; +import { CommandService } from './services/cmd/service'; + +const client = new Client({ + intents: [GatewayIntentBits.Guilds, GatewayIntentBits.GuildMembers, GatewayIntentBits.GuildMessages, GatewayIntentBits.MessageContent] +}); + +// SERVICES +// ----------------------- +const s = (() => { + const Commands = new CommandService(client); + return { Commands }; +})(); + +client.once(Events.ClientReady, async (readyClient) => { + // Initialize + await s.Commands.init(); + + console.log(`Ready! Logged in as ${readyClient.user.tag}`); +}); + +client.on("guildMemberAdd", async (guildMember) => { +}); + +client.on("messageCreate", async (message) => { + if (message.author.bot) return; + + const command = s.Commands.parse(message); + if (command) command.execute(message); +}); + +client.login(Bun.env.BOT_TOKEN); diff --git a/apps/bot/src/services/cmd/registry/stats.ts b/apps/bot/src/services/cmd/registry/stats.ts new file mode 100644 index 0000000..fb9df17 --- /dev/null +++ b/apps/bot/src/services/cmd/registry/stats.ts @@ -0,0 +1,41 @@ +import { EmbedBuilder, type Message } from "discord.js"; +import type { Command } from "../service"; +import { api } from "../../../util"; + +const ATTRIBUTE_EMOJI: Record = { + Strength: "💪", + Power: "⚡", + Endurance: "🏃", + Agility: "🤸", +}; + +export default { + name: "stats", + description: "View your fitness statistics!", + execute: async (message: Message) => { + const res = await api.accounts({ id: `@${message.author.id}` }).stats.get(); + if (!res.data) { + await message.reply("No stats found yet — submit an assessment first!"); + return; + } + + const { player, attributes } = res.data; + + const embed = new EmbedBuilder() + .setColor(0xEEE8AA) + .setTitle(`${message.author.username}'s Stats`) + .setThumbnail(message.author.displayAvatarURL()) + .setDescription(`**Overall Level: ${player}**`) + .addFields( + Object.entries(attributes).map(([attribute, level]) => ({ + name: `${ATTRIBUTE_EMOJI[attribute] ?? ""} ${attribute}`, + value: `Level ${level}`, + inline: true, + })) + ) + .setFooter({ text: "Blade & Brawn" }) + .setTimestamp(); + + await message.reply({ embeds: [embed] }); + } +} as Command; diff --git a/apps/bot/src/services/cmd/service.ts b/apps/bot/src/services/cmd/service.ts new file mode 100644 index 0000000..4b30d16 --- /dev/null +++ b/apps/bot/src/services/cmd/service.ts @@ -0,0 +1,33 @@ +import type { Client, Message } from "discord.js"; +import { readdir } from "fs/promises"; + +const CMD_PREFIX = "."; + +export interface Command { + name: string, + description: string, + execute: (message: Message) => void | Promise +} + +export class CommandService { + private client: Client + private registry: Record = {} + + constructor(client: Client) { + this.client = client; + } + + async init() { + const commands = await Promise.all( + (await readdir(`${import.meta.dir}/registry`)).map(async file => (await import(`./registry/${file}`)).default as Command) + ); + for (const command of commands) + this.registry[command.name] = command; + }; + + parse(message: Message): Command | undefined { + if (!message.content.startsWith(CMD_PREFIX)) return; + const name = message.content.slice(CMD_PREFIX.length).split(/\s+/)[0]; + return this.registry[name ?? ""]; + } +} diff --git a/apps/bot/src/util.ts b/apps/bot/src/util.ts new file mode 100644 index 0000000..08ed4c9 --- /dev/null +++ b/apps/bot/src/util.ts @@ -0,0 +1,38 @@ +import cluster from "node:cluster"; +import { pino } from "pino"; +import { treaty } from '@elysia/eden'; +import { type API } from "@blade-and-brawn/api"; + +export const log = pino({ + level: Bun.env.LOG_LEVEL ?? "info", + transport: Bun.env.NODE_ENV != "production" + ? { target: "pino-pretty" } + : undefined, +}); + +export const env = { + PUBLIC_API_URL: requireEnv("PUBLIC_API_URL"), + BOT_TOKEN: requireEnv("BOT_TOKEN"), +}; + +export const api = treaty(env.PUBLIC_API_URL ?? ""); + +function requireEnv(key: string): string { + const val = Bun.env[key]; + if (!val) { + if (cluster.worker?.id === 1) + log.error({ name: key }, "Missing required environment variable"); + throw new Error(`Missing required environment variable: ${key}`) + }; + return val; +} + +function optionEnv(key: string, fallback: string = ""): string { + const val = Bun.env[key]; + if (!val) { + if (cluster.worker?.id === 1) + log.warn({ name: key }, `Missing optional environmental variable, falling back to "${fallback}"`) + return fallback + }; + return val; +} diff --git a/apps/bot/tsconfig.json b/apps/bot/tsconfig.json new file mode 100644 index 0000000..02ec15a --- /dev/null +++ b/apps/bot/tsconfig.json @@ -0,0 +1,3 @@ +{ + "extends": "../../tsconfig.base.json" +} diff --git a/apps/portal/package.json b/apps/portal/package.json index a1ff7a3..f58b12e 100644 --- a/apps/portal/package.json +++ b/apps/portal/package.json @@ -3,13 +3,12 @@ "type": "module", "devDependencies": { "@blade-and-brawn/api": "workspace:*", - "@sveltejs/kit": "^2.70.2", + "@sveltejs/kit": "^2.70.3", "@sveltejs/vite-plugin-svelte": "^6.2.4", - "@types/bun": "^1.3.14", "elysia": "^1.4.29", - "svelte": "^5.56.8", + "svelte": "^5.56.10", "svelte-adapter-bun": "^1.0.1", - "svelte-check": "^4.7.4", + "svelte-check": "^4.7.6", "vite": "^7.3.6" }, "scripts": { @@ -26,8 +25,8 @@ "@blade-and-brawn/domain": "workspace:*", "@elysia/eden": "^1.4.10", "@tailwindcss/vite": "^4.3.3", - "daisyui": "^5.7.9", - "jose": "^6.2.5", + "daisyui": "^5.7.20", + "jose": "^6.2.10", "tailwindcss": "^4.3.3" } } diff --git a/apps/portal/src/lib/components/PlayersTable.svelte b/apps/portal/src/lib/components/PlayersTable.svelte index c3415e8..099e60c 100644 --- a/apps/portal/src/lib/components/PlayersTable.svelte +++ b/apps/portal/src/lib/components/PlayersTable.svelte @@ -1,4 +1,6 @@ -
+
+ +
-
-
- {#each calculations as calculation, index} -
-
- +{#if loadError} + +{/if} -
    -
  • -
    -
    OVERALL
    -
    - {calculation?.levels?.player || "N/A"} -
    -
    -
  • - - {#each Object.values(Attribute) as attribute} -
  • -
    -
    - {attribute} -
    -
    - {calculation?.levels?.attributes?.[ - attribute - ] || "N/A"} -
    -
    -
  • - {/each} -
- -
- (calculation.levels = levelCalculator.calculate( - calculation.player, - calculation.activityPerformances, - ))} - > -
- - -