From 35f9aa557461d603ab745c01990fd5c2fd3256c4 Mon Sep 17 00:00:00 2001 From: Bas van Rossem Date: Wed, 17 Jun 2026 15:54:52 +0200 Subject: [PATCH] feat(api): seed reference activities and enable CORS for the worker SPA --- apps/api/package.json | 3 ++- apps/api/src/app.ts | 11 ++++++++++ apps/api/src/auth.ts | 2 +- apps/api/src/db/seed.ts | 45 ++++++++++++++++++++++++++++++++++++++ apps/api/test/cors.test.ts | 29 ++++++++++++++++++++++++ apps/api/test/seed.test.ts | 34 ++++++++++++++++++++++++++++ 6 files changed, 122 insertions(+), 2 deletions(-) create mode 100644 apps/api/src/db/seed.ts create mode 100644 apps/api/test/cors.test.ts create mode 100644 apps/api/test/seed.test.ts diff --git a/apps/api/package.json b/apps/api/package.json index 0290efb..94e27f1 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -10,7 +10,8 @@ "test:watch": "vitest", "typecheck": "tsc --noEmit", "db:generate": "drizzle-kit generate", - "db:migrate": "tsx src/db/migrate.ts" + "db:migrate": "tsx src/db/migrate.ts", + "db:seed": "tsx src/db/seed.ts" }, "dependencies": { "@hono/node-server": "^1.13.7", diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index dc7f927..435a644 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -1,4 +1,5 @@ import { Hono } from 'hono'; +import { cors } from 'hono/cors'; import { health } from './routes/health'; import { me } from './routes/me'; import { activitiesRoutes } from './routes/activities'; @@ -7,6 +8,16 @@ import { auth } from './auth'; export function createApp(): Hono { const app = new Hono(); + app.use( + '/api/*', + cors({ + origin: ['http://localhost:5173'], + allowMethods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'], + allowHeaders: ['Content-Type', 'Authorization'], + exposeHeaders: ['set-auth-token'], // so the SPA can read the bearer token on sign-in + credentials: true, + }) + ); app.route('/', health); app.on(['POST', 'GET'], '/api/auth/*', (c) => auth.handler(c.req.raw)); app.route('/', me); diff --git a/apps/api/src/auth.ts b/apps/api/src/auth.ts index 86cf5ba..0483a9e 100644 --- a/apps/api/src/auth.ts +++ b/apps/api/src/auth.ts @@ -8,7 +8,7 @@ import { env } from './env'; export const auth = betterAuth({ secret: env.BETTER_AUTH_SECRET, baseURL: env.BETTER_AUTH_URL, - trustedOrigins: [env.BETTER_AUTH_URL, 'http://localhost:3000'], + trustedOrigins: [env.BETTER_AUTH_URL, 'http://localhost:3000', 'http://localhost:5173'], database: drizzleAdapter(db, { provider: 'sqlite', schema }), emailAndPassword: { enabled: true, diff --git a/apps/api/src/db/seed.ts b/apps/api/src/db/seed.ts new file mode 100644 index 0000000..fbfd814 --- /dev/null +++ b/apps/api/src/db/seed.ts @@ -0,0 +1,45 @@ +import { pathToFileURL } from 'node:url'; +import { eq } from 'drizzle-orm'; +import { db } from './client'; +import { activities } from './schema'; + +// Reference activities (realistic Dutch handeling names) — see +// docs/reference/legacy-mobile-app.md §6.2 and the Phase 1 plan. +const REFERENCE_ACTIVITIES: { name: string; insoleTypes: string[] }[] = [ + { name: 'Leerrand', insoleTypes: ['Kurk', 'Berk', '3D'] }, + { name: 'Frezen', insoleTypes: ['Kurk', 'Berk'] }, + { name: 'Slijpen', insoleTypes: ['Kurk', 'Berk', '3D'] }, + { name: 'Bekleden', insoleTypes: ['Kurk', 'Berk', '3D'] }, + { name: 'Afwerken', insoleTypes: ['Kurk', 'Berk', '3D'] }, + { name: 'Printen', insoleTypes: ['3D'] }, +]; + +// Idempotent: insert each reference activity only if no activity with that name exists. +export async function seed(): Promise { + for (const activity of REFERENCE_ACTIVITIES) { + const existing = await db + .select() + .from(activities) + .where(eq(activities.name, activity.name)); + if (existing.length === 0) { + await db.insert(activities).values({ + name: activity.name, + insoleTypes: activity.insoleTypes, + }); + } + } +} + +// Allow running directly: `tsx src/db/seed.ts` (cross-platform — pathToFileURL +// handles Windows drive-letter/backslash paths, which a raw `file://` prefix does not). +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + seed() + .then(() => { + console.log('Seed complete.'); + process.exit(0); + }) + .catch((err) => { + console.error(err); + process.exit(1); + }); +} diff --git a/apps/api/test/cors.test.ts b/apps/api/test/cors.test.ts new file mode 100644 index 0000000..9a6e525 --- /dev/null +++ b/apps/api/test/cors.test.ts @@ -0,0 +1,29 @@ +import { describe, it, expect } from 'vitest'; +import { createApp } from '../src/app'; + +const ORIGIN = 'http://localhost:5173'; + +describe('cors', () => { + it('answers a CORS preflight for the SPA origin', async () => { + const app = createApp(); + const res = await app.request('/api/activities', { + method: 'OPTIONS', + headers: { + Origin: ORIGIN, + 'Access-Control-Request-Method': 'GET', + }, + }); + expect(res.headers.get('access-control-allow-origin')).toBe(ORIGIN); + const allowMethods = res.headers.get('access-control-allow-methods') ?? ''; + expect(allowMethods).toContain('GET'); + }); + + it('exposes set-auth-token to the SPA origin', async () => { + const app = createApp(); + const res = await app.request('/api/activities', { + headers: { Origin: ORIGIN }, + }); + const expose = (res.headers.get('access-control-expose-headers') ?? '').toLowerCase(); + expect(expose).toContain('set-auth-token'); + }); +}); diff --git a/apps/api/test/seed.test.ts b/apps/api/test/seed.test.ts new file mode 100644 index 0000000..637b017 --- /dev/null +++ b/apps/api/test/seed.test.ts @@ -0,0 +1,34 @@ +import { describe, it, expect } from 'vitest'; +import { inArray, eq } from 'drizzle-orm'; +import { seed } from '../src/db/seed'; +import { db } from '../src/db/client'; +import { activities } from '../src/db/schema'; + +const SEED_NAMES = ['Leerrand', 'Frezen', 'Slijpen', 'Bekleden', 'Afwerken', 'Printen']; + +describe('seed', () => { + it('seeds the reference activities idempotently', async () => { + await seed(); + const first = await db + .select() + .from(activities) + .where(inArray(activities.name, SEED_NAMES)); + const countFirst = first.length; + + await seed(); + const second = await db + .select() + .from(activities) + .where(inArray(activities.name, SEED_NAMES)); + + expect(second.length).toBe(countFirst); + expect(countFirst).toBe(SEED_NAMES.length); + + const printen = await db + .select() + .from(activities) + .where(eq(activities.name, 'Printen')); + expect(printen).toHaveLength(1); + expect(printen[0]?.insoleTypes).toEqual(['3D']); + }); +});