feat(api): seed reference activities and enable CORS for the worker SPA
This commit is contained in:
@@ -10,7 +10,8 @@
|
|||||||
"test:watch": "vitest",
|
"test:watch": "vitest",
|
||||||
"typecheck": "tsc --noEmit",
|
"typecheck": "tsc --noEmit",
|
||||||
"db:generate": "drizzle-kit generate",
|
"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": {
|
"dependencies": {
|
||||||
"@hono/node-server": "^1.13.7",
|
"@hono/node-server": "^1.13.7",
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { Hono } from 'hono';
|
import { Hono } from 'hono';
|
||||||
|
import { cors } from 'hono/cors';
|
||||||
import { health } from './routes/health';
|
import { health } from './routes/health';
|
||||||
import { me } from './routes/me';
|
import { me } from './routes/me';
|
||||||
import { activitiesRoutes } from './routes/activities';
|
import { activitiesRoutes } from './routes/activities';
|
||||||
@@ -7,6 +8,16 @@ import { auth } from './auth';
|
|||||||
|
|
||||||
export function createApp(): Hono {
|
export function createApp(): Hono {
|
||||||
const app = new 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.route('/', health);
|
||||||
app.on(['POST', 'GET'], '/api/auth/*', (c) => auth.handler(c.req.raw));
|
app.on(['POST', 'GET'], '/api/auth/*', (c) => auth.handler(c.req.raw));
|
||||||
app.route('/', me);
|
app.route('/', me);
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import { env } from './env';
|
|||||||
export const auth = betterAuth({
|
export const auth = betterAuth({
|
||||||
secret: env.BETTER_AUTH_SECRET,
|
secret: env.BETTER_AUTH_SECRET,
|
||||||
baseURL: env.BETTER_AUTH_URL,
|
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 }),
|
database: drizzleAdapter(db, { provider: 'sqlite', schema }),
|
||||||
emailAndPassword: {
|
emailAndPassword: {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
|
|||||||
45
apps/api/src/db/seed.ts
Normal file
45
apps/api/src/db/seed.ts
Normal file
@@ -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<void> {
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
}
|
||||||
29
apps/api/test/cors.test.ts
Normal file
29
apps/api/test/cors.test.ts
Normal file
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
34
apps/api/test/seed.test.ts
Normal file
34
apps/api/test/seed.test.ts
Normal file
@@ -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']);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user