From f2cc0973c7011836e5ded68b6ae61c0929be3042 Mon Sep 17 00:00:00 2001 From: Bas van Rossem Date: Wed, 17 Jun 2026 17:43:37 +0200 Subject: [PATCH] feat(api): role-aware session helper + admin-only activity writes --- apps/api/src/lib/require-user.ts | 14 ++++++++++++-- apps/api/src/lib/work-session.ts | 27 ++++++++++++++++++++++++++ apps/api/src/routes/activities.ts | 5 ++++- apps/api/src/routes/sessions.ts | 32 +++++++------------------------ apps/api/test/activities.test.ts | 32 ++++++++++++++++++++++++------- apps/api/test/export.test.ts | 16 +++------------- 6 files changed, 78 insertions(+), 48 deletions(-) create mode 100644 apps/api/src/lib/work-session.ts diff --git a/apps/api/src/lib/require-user.ts b/apps/api/src/lib/require-user.ts index f2cd7d2..954f764 100644 --- a/apps/api/src/lib/require-user.ts +++ b/apps/api/src/lib/require-user.ts @@ -1,8 +1,18 @@ import type { Context } from 'hono'; import { auth } from '../auth'; -export async function getSessionUser(c: Context): Promise<{ id: string } | null> { +export interface SessionUser { + id: string; + role: string; +} + +export async function getSessionUser(c: Context): Promise { const session = await auth.api.getSession({ headers: c.req.raw.headers }); if (!session) return null; - return { id: session.user.id }; + const role = (session.user as { role?: string | null }).role ?? 'worker'; + return { id: session.user.id, role }; +} + +export function isAdmin(u: SessionUser | null): boolean { + return u?.role === 'admin'; } diff --git a/apps/api/src/lib/work-session.ts b/apps/api/src/lib/work-session.ts new file mode 100644 index 0000000..94116a7 --- /dev/null +++ b/apps/api/src/lib/work-session.ts @@ -0,0 +1,27 @@ +import type { WorkSession } from '@solelog/shared'; +import type { workSessions } from '../db/schema'; + +type WorkSessionRow = typeof workSessions.$inferSelect; + +export function toWorkSession( + row: WorkSessionRow, + opts: { activityName?: string | null; userName?: string | null; userEmail?: string | null } = {} +): WorkSession { + return { + id: row.id, + user_id: row.userId, + activity_id: row.activityId, + activity_name: opts.activityName ?? undefined, + user_name: opts.userName ?? undefined, + user_email: opts.userEmail ?? undefined, + insole_type: (row.insoleType ?? null) as WorkSession['insole_type'], + pair_count: row.pairCount, + start_time: new Date(row.startTime).toISOString(), + end_time: row.endTime ? new Date(row.endTime).toISOString() : null, + duration_seconds: row.durationSeconds ?? null, + status: row.status as WorkSession['status'], + source: row.source as WorkSession['source'], + notes: row.notes ?? null, + created_at: new Date(row.createdAt).toISOString(), + }; +} diff --git a/apps/api/src/routes/activities.ts b/apps/api/src/routes/activities.ts index 5636709..b6eb38b 100644 --- a/apps/api/src/routes/activities.ts +++ b/apps/api/src/routes/activities.ts @@ -4,7 +4,7 @@ import { CreateActivityInput, UpdateActivityInput } from '@solelog/shared'; import type { Activity } from '@solelog/shared'; import { db } from '../db/client'; import { activities, workSessions } from '../db/schema'; -import { getSessionUser } from '../lib/require-user'; +import { getSessionUser, isAdmin } from '../lib/require-user'; export const activitiesRoutes = new Hono(); @@ -34,6 +34,7 @@ activitiesRoutes.get('/api/activities', async (c) => { activitiesRoutes.post('/api/activities', async (c) => { const sessionUser = await getSessionUser(c); if (!sessionUser) return c.json({ error: 'Unauthorized' }, 401); + if (!isAdmin(sessionUser)) return c.json({ error: 'Forbidden' }, 403); const parsed = CreateActivityInput.safeParse(await c.req.json().catch(() => null)); if (!parsed.success) return c.json({ error: 'Invalid input' }, 400); @@ -48,6 +49,7 @@ activitiesRoutes.post('/api/activities', async (c) => { activitiesRoutes.put('/api/activities/:id', async (c) => { const sessionUser = await getSessionUser(c); if (!sessionUser) return c.json({ error: 'Unauthorized' }, 401); + if (!isAdmin(sessionUser)) return c.json({ error: 'Forbidden' }, 403); const id = Number.parseInt(c.req.param('id'), 10); if (Number.isNaN(id)) return c.json({ error: 'Activity not found' }, 404); @@ -67,6 +69,7 @@ activitiesRoutes.put('/api/activities/:id', async (c) => { activitiesRoutes.delete('/api/activities/:id', async (c) => { const sessionUser = await getSessionUser(c); if (!sessionUser) return c.json({ error: 'Unauthorized' }, 401); + if (!isAdmin(sessionUser)) return c.json({ error: 'Forbidden' }, 403); const id = Number.parseInt(c.req.param('id'), 10); if (Number.isNaN(id)) return c.json({ error: 'Activity not found' }, 404); diff --git a/apps/api/src/routes/sessions.ts b/apps/api/src/routes/sessions.ts index 495a649..5cbaa90 100644 --- a/apps/api/src/routes/sessions.ts +++ b/apps/api/src/routes/sessions.ts @@ -1,34 +1,14 @@ import { Hono } from 'hono'; import { and, asc, desc, eq } from 'drizzle-orm'; import { StartSessionInput } from '@solelog/shared'; -import type { WorkSession } from '@solelog/shared'; import { db } from '../db/client'; import { activities, workSessions } from '../db/schema'; import { getSessionUser } from '../lib/require-user'; +import { toWorkSession } from '../lib/work-session'; import { quote, formatDuration } from '../lib/csv'; export const sessionsRoutes = new Hono(); -type WorkSessionRow = typeof workSessions.$inferSelect; - -function toWorkSession(row: WorkSessionRow, activityName?: string | null): WorkSession { - return { - id: row.id, - user_id: row.userId, - activity_id: row.activityId, - activity_name: activityName ?? undefined, - insole_type: (row.insoleType ?? null) as WorkSession['insole_type'], - pair_count: row.pairCount, - start_time: new Date(row.startTime).toISOString(), - end_time: row.endTime ? new Date(row.endTime).toISOString() : null, - duration_seconds: row.durationSeconds ?? null, - status: row.status as WorkSession['status'], - source: row.source as WorkSession['source'], - notes: row.notes ?? null, - created_at: new Date(row.createdAt).toISOString(), - }; -} - sessionsRoutes.get('/api/export', async (c) => { const sessionUser = await getSessionUser(c); if (!sessionUser) return c.json({ error: 'Unauthorized' }, 401); @@ -95,7 +75,7 @@ sessionsRoutes.get('/api/sessions/active', async (c) => { .where(and(eq(workSessions.userId, sessionUser.id), eq(workSessions.status, 'active'))) .orderBy(desc(workSessions.startTime)); - return c.json(rows.map((r) => toWorkSession(r.session, r.activityName))); + return c.json(rows.map((r) => toWorkSession(r.session, { activityName: r.activityName }))); }); sessionsRoutes.get('/api/sessions', async (c) => { @@ -109,7 +89,7 @@ sessionsRoutes.get('/api/sessions', async (c) => { .where(eq(workSessions.userId, sessionUser.id)) .orderBy(desc(workSessions.startTime)); - return c.json(rows.map((r) => toWorkSession(r.session, r.activityName))); + return c.json(rows.map((r) => toWorkSession(r.session, { activityName: r.activityName }))); }); sessionsRoutes.post('/api/sessions/start', async (c) => { @@ -139,7 +119,7 @@ sessionsRoutes.post('/api/sessions/start', async (c) => { source: 'app', }) .returning(); - return c.json(toWorkSession(row, activity.name)); + return c.json(toWorkSession(row, { activityName: activity.name })); }); sessionsRoutes.post('/api/sessions/:id/stop', async (c) => { @@ -157,7 +137,9 @@ sessionsRoutes.post('/api/sessions/:id/stop', async (c) => { if (row.status !== 'active') return c.json({ error: 'Session already closed' }, 409); const endTime = new Date(); - const durationSeconds = Math.round((endTime.getTime() - new Date(row.startTime).getTime()) / 1000); + const durationSeconds = Math.round( + (endTime.getTime() - new Date(row.startTime).getTime()) / 1000 + ); const [updated] = await db .update(workSessions) diff --git a/apps/api/test/activities.test.ts b/apps/api/test/activities.test.ts index ed4a7f9..5acba2d 100644 --- a/apps/api/test/activities.test.ts +++ b/apps/api/test/activities.test.ts @@ -24,9 +24,27 @@ describe('activities routes', () => { expect(res.status).toBe(401); }); + it('forbids a worker from creating an activity (403)', async () => { + const app = createApp(); + const token = await authToken(app, 'act-worker-create@example.com'); // default worker + const res = await app.request('/api/activities', { + method: 'POST', + headers: bearer(token), + body: JSON.stringify({ name: 'Frezen', insole_types: ['Kurk'] }), + }); + expect(res.status).toBe(403); + }); + + it('lets a worker read activities (200)', async () => { + const app = createApp(); + const token = await authToken(app, 'act-worker-read@example.com'); + const res = await app.request('/api/activities', { headers: bearer(token) }); + expect(res.status).toBe(200); + }); + it('creates an activity and lists it', async () => { const app = createApp(); - const token = await authToken(app, 'act-create@example.com'); + const token = await authToken(app, 'act-create@example.com', 'admin'); const createRes = await app.request('/api/activities', { method: 'POST', @@ -49,7 +67,7 @@ describe('activities routes', () => { it('defaults insole_types to all three when omitted', async () => { const app = createApp(); - const token = await authToken(app, 'act-default@example.com'); + const token = await authToken(app, 'act-default@example.com', 'admin'); const res = await app.request('/api/activities', { method: 'POST', @@ -63,7 +81,7 @@ describe('activities routes', () => { it('filters by ?insole_type', async () => { const app = createApp(); - const token = await authToken(app, 'act-filter@example.com'); + const token = await authToken(app, 'act-filter@example.com', 'admin'); const printRes = await app.request('/api/activities', { method: 'POST', @@ -87,7 +105,7 @@ describe('activities routes', () => { it('400s POST with an empty name', async () => { const app = createApp(); - const token = await authToken(app, 'act-emptyname@example.com'); + const token = await authToken(app, 'act-emptyname@example.com', 'admin'); const res = await app.request('/api/activities', { method: 'POST', @@ -99,7 +117,7 @@ describe('activities routes', () => { it('updates an activity', async () => { const app = createApp(); - const token = await authToken(app, 'act-update@example.com'); + const token = await authToken(app, 'act-update@example.com', 'admin'); const createRes = await app.request('/api/activities', { method: 'POST', @@ -122,7 +140,7 @@ describe('activities routes', () => { it('404s PUT for a missing id', async () => { const app = createApp(); - const token = await authToken(app, 'act-put404@example.com'); + const token = await authToken(app, 'act-put404@example.com', 'admin'); const res = await app.request('/api/activities/999999', { method: 'PUT', @@ -134,7 +152,7 @@ describe('activities routes', () => { it('deletes an activity and its sessions', async () => { const app = createApp(); - const token = await authToken(app, 'act-delete@example.com'); + const token = await authToken(app, 'act-delete@example.com', 'admin'); const createRes = await app.request('/api/activities', { method: 'POST', diff --git a/apps/api/test/export.test.ts b/apps/api/test/export.test.ts index 73973b9..8e3bed6 100644 --- a/apps/api/test/export.test.ts +++ b/apps/api/test/export.test.ts @@ -5,17 +5,7 @@ import { db } from '../src/db/client'; import { workSessions } from '../src/db/schema'; import { eq } from 'drizzle-orm'; import { quote, formatDuration } from '../src/lib/csv'; -import { authToken, bearer } from './helpers'; - -async function createActivity(app: Hono, token: string, name: string): Promise { - const res = await app.request('/api/activities', { - method: 'POST', - headers: bearer(token), - body: JSON.stringify({ name, insole_types: ['Kurk', 'Berk', '3D'] }), - }); - const body = await res.json(); - return body.id as number; -} +import { authToken, bearer, seedActivity } from './helpers'; // Start a session, backdate its start_time by `durationSeconds`, then stop it, // producing a completed session with an exact duration. @@ -50,7 +40,7 @@ describe('csv export', () => { it('exports completed sessions as CSV with the legacy header', async () => { const app = createApp(); const token = await authToken(app, 'export-basic@example.com'); - const activityId = await createActivity(app, token, 'Frezen'); + const activityId = await seedActivity('Frezen'); await completedSession(app, token, activityId, 'Kurk', 90); const res = await app.request('/api/export', { headers: bearer(token) }); @@ -75,7 +65,7 @@ describe('csv export', () => { const app = createApp(); const tokenA = await authToken(app, 'export-scopeA@example.com'); const tokenB = await authToken(app, 'export-scopeB@example.com'); - const activityId = await createActivity(app, tokenA, 'Slijpen'); + const activityId = await seedActivity('Slijpen'); // User A: one completed session. await completedSession(app, tokenA, activityId, 'Kurk', 30);