From dc8f550665a15f5d762a4f8616baafde63083275 Mon Sep 17 00:00:00 2001 From: Bas van Rossem Date: Wed, 17 Jun 2026 17:47:17 +0200 Subject: [PATCH] feat(api): admin-only cross-user work-session views (/api/admin/sessions) --- apps/api/src/app.ts | 2 ++ apps/api/src/routes/admin.ts | 60 ++++++++++++++++++++++++++++++++++++ apps/api/test/admin.test.ts | 46 +++++++++++++++++++++++++++ 3 files changed, 108 insertions(+) create mode 100644 apps/api/src/routes/admin.ts create mode 100644 apps/api/test/admin.test.ts diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index 11cff19..8fcc9b4 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -4,6 +4,7 @@ import { health } from './routes/health'; import { me } from './routes/me'; import { activitiesRoutes } from './routes/activities'; import { sessionsRoutes } from './routes/sessions'; +import { adminRoutes } from './routes/admin'; import { auth } from './auth'; import { env } from './env'; @@ -24,5 +25,6 @@ export function createApp(): Hono { app.route('/', me); app.route('/', activitiesRoutes); app.route('/', sessionsRoutes); + app.route('/', adminRoutes); return app; } diff --git a/apps/api/src/routes/admin.ts b/apps/api/src/routes/admin.ts new file mode 100644 index 0000000..e6c08a5 --- /dev/null +++ b/apps/api/src/routes/admin.ts @@ -0,0 +1,60 @@ +import { Hono } from 'hono'; +import { desc, eq } from 'drizzle-orm'; +import { db } from '../db/client'; +import { activities, user, workSessions } from '../db/schema'; +import { getSessionUser, isAdmin } from '../lib/require-user'; +import { toWorkSession } from '../lib/work-session'; + +export const adminRoutes = new Hono(); + +// Gate the whole /api/admin/* surface to admins. +adminRoutes.use('/api/admin/*', async (c, next) => { + const sessionUser = await getSessionUser(c); + if (!sessionUser) return c.json({ error: 'Unauthorized' }, 401); + if (!isAdmin(sessionUser)) return c.json({ error: 'Forbidden' }, 403); + await next(); +}); + +const baseSelect = { + session: workSessions, + activityName: activities.name, + userName: user.name, + userEmail: user.email, +}; + +adminRoutes.get('/api/admin/sessions', async (c) => { + const rows = await db + .select(baseSelect) + .from(workSessions) + .leftJoin(activities, eq(workSessions.activityId, activities.id)) + .leftJoin(user, eq(workSessions.userId, user.id)) + .orderBy(desc(workSessions.startTime)); + return c.json( + rows.map((r) => + toWorkSession(r.session, { + activityName: r.activityName, + userName: r.userName, + userEmail: r.userEmail, + }) + ) + ); +}); + +adminRoutes.get('/api/admin/sessions/active', async (c) => { + const rows = await db + .select(baseSelect) + .from(workSessions) + .leftJoin(activities, eq(workSessions.activityId, activities.id)) + .leftJoin(user, eq(workSessions.userId, user.id)) + .where(eq(workSessions.status, 'active')) + .orderBy(desc(workSessions.startTime)); + return c.json( + rows.map((r) => + toWorkSession(r.session, { + activityName: r.activityName, + userName: r.userName, + userEmail: r.userEmail, + }) + ) + ); +}); diff --git a/apps/api/test/admin.test.ts b/apps/api/test/admin.test.ts new file mode 100644 index 0000000..d786c0d --- /dev/null +++ b/apps/api/test/admin.test.ts @@ -0,0 +1,46 @@ +import { describe, it, expect } from 'vitest'; +import { createApp } from '../src/app'; +import { authToken, bearer, seedActivity } from './helpers'; + +describe('admin session views', () => { + it('401s without a token', async () => { + const app = createApp(); + expect((await app.request('/api/admin/sessions')).status).toBe(401); + expect((await app.request('/api/admin/sessions/active')).status).toBe(401); + }); + + it('403s for a worker', async () => { + const app = createApp(); + const token = await authToken(app, 'admin-view-worker@example.com'); // worker + expect((await app.request('/api/admin/sessions', { headers: bearer(token) })).status).toBe(403); + }); + + it("returns ALL users' sessions for an admin, with user info", async () => { + const app = createApp(); + const adminTok = await authToken(app, 'admin-view-admin@example.com', 'admin'); + const workerTok = await authToken(app, 'admin-view-w2@example.com'); // worker + const activityId = await seedActivity('Frezen'); + + // Worker starts a session. + const started = await ( + await app.request('/api/sessions/start', { + method: 'POST', + headers: bearer(workerTok), + body: JSON.stringify({ activity_id: activityId, insole_type: 'Kurk', pair_count: 2 }), + }) + ).json(); + + const res = await app.request('/api/admin/sessions', { headers: bearer(adminTok) }); + expect(res.status).toBe(200); + const body = await res.json(); + const found = body.find((s: { id: number }) => s.id === started.id); + expect(found).toBeTruthy(); + expect(found.user_email).toBe('admin-view-w2@example.com'); + expect(found.activity_name).toBe('Frezen'); + + const active = await app.request('/api/admin/sessions/active', { headers: bearer(adminTok) }); + expect(active.status).toBe(200); + const activeBody = await active.json(); + expect(activeBody.some((s: { id: number }) => s.id === started.id)).toBe(true); + }); +});