From 4b213e25a80d20b406f8da142a602a4805af7dff Mon Sep 17 00:00:00 2001 From: Bas van Rossem Date: Wed, 24 Jun 2026 16:25:36 +0200 Subject: [PATCH] feat(api): admin report endpoint + ReportResponse contracts --- apps/api/src/routes/admin.ts | 161 ++++++++++++++++++++++++++++++++++- apps/api/test/report.test.ts | 135 +++++++++++++++++++++++++++++ packages/shared/src/index.ts | 34 ++++++++ 3 files changed, 328 insertions(+), 2 deletions(-) create mode 100644 apps/api/test/report.test.ts diff --git a/apps/api/src/routes/admin.ts b/apps/api/src/routes/admin.ts index d0518d2..cb6046b 100644 --- a/apps/api/src/routes/admin.ts +++ b/apps/api/src/routes/admin.ts @@ -1,6 +1,6 @@ import { Hono } from 'hono'; -import { asc, desc, eq } from 'drizzle-orm'; -import { AdminUpdateSessionInput, CreateManualSessionInput } from '@solelog/shared'; +import { and, asc, desc, eq, gte, lte, type SQL } from 'drizzle-orm'; +import { AdminUpdateSessionInput, CreateManualSessionInput, InsoleType } from '@solelog/shared'; import { db } from '../db/client'; import { activities, user, workSessions } from '../db/schema'; import { getSessionUser, isAdmin } from '../lib/require-user'; @@ -10,6 +10,51 @@ function computeDuration(startMs: number, endMs: number, paused: number): number return Math.max(0, Math.round((endMs - startMs) / 1000) - paused); } +type ReportQuery = { + from: Date; + to: Date; + userId?: string; + insoleType?: string; + activityId?: number; +}; + +// Parse + validate the shared report/export query params. Returns null on a bad range. +function parseReportQuery(c: { + req: { query: (k: string) => string | undefined }; +}): ReportQuery | null { + const fromRaw = c.req.query('from'); + const toRaw = c.req.query('to'); + if (!fromRaw || !toRaw) return null; + const from = new Date(fromRaw); + const to = new Date(toRaw); + if (Number.isNaN(from.getTime()) || Number.isNaN(to.getTime()) || to < from) return null; + + const insoleType = c.req.query('insole_type') || undefined; + if (insoleType && !InsoleType.safeParse(insoleType).success) return null; + + const activityIdRaw = c.req.query('activity_id'); + let activityId: number | undefined; + if (activityIdRaw) { + activityId = Number.parseInt(activityIdRaw, 10); + if (Number.isNaN(activityId)) return null; + } + + return { from, to, userId: c.req.query('user_id') || undefined, insoleType, activityId }; +} + +// completed + date-range + optional worker/type/activity — shared by report and export. +function buildSessionFilters(q: ReportQuery): SQL[] { + const conds: SQL[] = [ + eq(workSessions.status, 'completed'), + gte(workSessions.startTime, q.from), + lte(workSessions.startTime, q.to), + ]; + if (q.userId) conds.push(eq(workSessions.userId, q.userId)); + if (q.insoleType) conds.push(eq(workSessions.insoleType, q.insoleType)); + if (q.activityId !== undefined) conds.push(eq(workSessions.activityId, q.activityId)); + return conds; +} + export const adminRoutes = new Hono(); // Gate the whole /api/admin/* surface to admins. @@ -73,6 +118,118 @@ adminRoutes.get('/api/admin/users', async (c) => { return c.json(rows); }); +adminRoutes.get('/api/admin/report', async (c) => { + const q = parseReportQuery(c); + if (!q) return c.json({ error: 'Invalid query' }, 400); + + const rows = await db + .select(baseSelect) + .from(workSessions) + .leftJoin(activities, eq(workSessions.activityId, activities.id)) + .leftJoin(user, eq(workSessions.userId, user.id)) + .where(and(...buildSessionFilters(q))); + + const totals = { worked_seconds: 0, paused_seconds: 0, pairs: 0, sessions: 0 }; + const workers = new Map< + string, + { + user_id: string; + user_name: string; + worked_seconds: number; + paused_seconds: number; + pairs: number; + sessions: number; + } + >(); + const acts = new Map< + number, + { + activity_id: number; + activity_name: string; + worked_seconds: number; + paused_seconds: number; + pairs: number; + sessions: number; + } + >(); + const types = new Map< + string, + { + insole_type: string; + worked_seconds: number; + paused_seconds: number; + pairs: number; + sessions: number; + } + >(); + + for (const r of rows) { + const worked = r.session.durationSeconds ?? 0; + const paused = r.session.pausedSeconds ?? 0; + const pairs = r.session.pairCount ?? 0; + + totals.worked_seconds += worked; + totals.paused_seconds += paused; + totals.pairs += pairs; + totals.sessions += 1; + + const uid = r.session.userId; + const w = workers.get(uid) ?? { + user_id: uid, + user_name: r.userName ?? 'Onbekend', + worked_seconds: 0, + paused_seconds: 0, + pairs: 0, + sessions: 0, + }; + w.worked_seconds += worked; + w.paused_seconds += paused; + w.pairs += pairs; + w.sessions += 1; + workers.set(uid, w); + + const aid = r.session.activityId; + const a = acts.get(aid) ?? { + activity_id: aid, + activity_name: r.activityName ?? 'Onbekend', + worked_seconds: 0, + paused_seconds: 0, + pairs: 0, + sessions: 0, + }; + a.worked_seconds += worked; + a.paused_seconds += paused; + a.pairs += pairs; + a.sessions += 1; + acts.set(aid, a); + + const tkey = r.session.insoleType ?? 'Onbekend'; + const t = types.get(tkey) ?? { + insole_type: tkey, + worked_seconds: 0, + paused_seconds: 0, + pairs: 0, + sessions: 0, + }; + t.worked_seconds += worked; + t.paused_seconds += paused; + t.pairs += pairs; + t.sessions += 1; + types.set(tkey, t); + } + + const byWorked = (x: { worked_seconds: number }, y: { worked_seconds: number }) => + y.worked_seconds - x.worked_seconds; + + return c.json({ + range: { from: q.from.toISOString(), to: q.to.toISOString() }, + totals, + by_worker: [...workers.values()].sort(byWorked), + by_activity: [...acts.values()].sort(byWorked), + by_type: [...types.values()].sort(byWorked), + }); +}); + // Manual create → always completed, source='manual', duration derived server-side. adminRoutes.post('/api/admin/sessions', async (c) => { const parsed = CreateManualSessionInput.safeParse(await c.req.json().catch(() => null)); diff --git a/apps/api/test/report.test.ts b/apps/api/test/report.test.ts new file mode 100644 index 0000000..69396c3 --- /dev/null +++ b/apps/api/test/report.test.ts @@ -0,0 +1,135 @@ +import { describe, it, expect } from 'vitest'; +import type { Hono } from 'hono'; +import { createApp } from '../src/app'; +import { db } from '../src/db/client'; +import { workSessions } from '../src/db/schema'; +import { eq } from 'drizzle-orm'; +import { authToken, bearer, seedActivity } from './helpers'; + +const WIDE = `from=${new Date('2000-01-01').toISOString()}&to=${new Date('2100-01-01').toISOString()}`; + +async function completed( + app: Hono, + token: string, + activityId: number, + insoleType: string, + durationSeconds: number, + pairCount = 2, +): Promise { + const startRes = await app.request('/api/sessions/start', { + method: 'POST', + headers: bearer(token), + body: JSON.stringify({ + activity_id: activityId, + insole_type: insoleType, + pair_count: pairCount, + }), + }); + const started = await startRes.json(); + await db + .update(workSessions) + .set({ startTime: new Date(Date.now() - durationSeconds * 1000) }) + .where(eq(workSessions.id, started.id)); + await app.request(`/api/sessions/${started.id}/stop`, { method: 'POST', headers: bearer(token) }); + return started.id as number; +} + +describe('GET /api/admin/report', () => { + it('401s without a token and 403s for a worker', async () => { + const app = createApp(); + expect((await app.request(`/api/admin/report?${WIDE}`)).status).toBe(401); + const workerTok = await authToken(app, 'report-worker@example.com'); + const res = await app.request(`/api/admin/report?${WIDE}`, { headers: bearer(workerTok) }); + expect(res.status).toBe(403); + }); + + it('400s on a missing or inverted range', async () => { + const app = createApp(); + const adminTok = await authToken(app, 'report-badrange@example.com', 'admin'); + expect((await app.request('/api/admin/report', { headers: bearer(adminTok) })).status).toBe( + 400, + ); + const inverted = `from=${new Date('2026-02-01').toISOString()}&to=${new Date('2026-01-01').toISOString()}`; + expect( + (await app.request(`/api/admin/report?${inverted}`, { headers: bearer(adminTok) })).status, + ).toBe(400); + }); + + it('aggregates totals that equal the sum of each breakdown', async () => { + const app = createApp(); + const adminTok = await authToken(app, 'report-agg-admin@example.com', 'admin'); + const workerTok = await authToken(app, 'report-agg-worker@example.com'); // 'report-agg-worker' + const frezen = await seedActivity('Frezen'); + const lijmen = await seedActivity('Lijmen'); + await completed(app, workerTok, frezen, 'Kurk', 100, 2); + await completed(app, workerTok, lijmen, 'Berk', 50, 3); + + const res = await app.request(`/api/admin/report?${WIDE}`, { headers: bearer(adminTok) }); + expect(res.status).toBe(200); + const body = await res.json(); + + expect(body.totals.worked_seconds).toBe(150); + expect(body.totals.pairs).toBe(5); + expect(body.totals.sessions).toBe(2); + + const sum = (rows: { worked_seconds: number; pairs: number; sessions: number }[]) => + rows.reduce( + (a, r) => ({ + worked_seconds: a.worked_seconds + r.worked_seconds, + pairs: a.pairs + r.pairs, + sessions: a.sessions + r.sessions, + }), + { worked_seconds: 0, pairs: 0, sessions: 0 }, + ); + expect(sum(body.by_worker)).toEqual({ worked_seconds: 150, pairs: 5, sessions: 2 }); + expect(sum(body.by_activity)).toEqual({ worked_seconds: 150, pairs: 5, sessions: 2 }); + expect(sum(body.by_type)).toEqual({ worked_seconds: 150, pairs: 5, sessions: 2 }); + expect(body.by_activity).toHaveLength(2); + expect(body.by_type.map((t: { insole_type: string }) => t.insole_type).sort()).toEqual([ + 'Berk', + 'Kurk', + ]); + }); + + it('excludes active/discarded and respects the user and type filters', async () => { + const app = createApp(); + const adminTok = await authToken(app, 'report-filter-admin@example.com', 'admin'); + const a = await authToken(app, 'report-filter-a@example.com'); + const b = await authToken(app, 'report-filter-b@example.com'); + const act = await seedActivity('Slijpen'); + await completed(app, a, act, 'Kurk', 30, 2); // counts for A/Kurk + await completed(app, b, act, 'Berk', 40, 2); // counts for B/Berk + // A: an active session (no duration) — must be excluded. + await app.request('/api/sessions/start', { + method: 'POST', + headers: bearer(a), + body: JSON.stringify({ activity_id: act, insole_type: 'Kurk', pair_count: 2 }), + }); + + // Resolve A's user id via the roster. + const roster = await ( + await app.request('/api/admin/users', { headers: bearer(adminTok) }) + ).json(); + const userA = roster.find((u: { email: string }) => u.email === 'report-filter-a@example.com'); + + const res = await app.request( + `/api/admin/report?${WIDE}&user_id=${userA.id}&insole_type=Kurk`, + { headers: bearer(adminTok) }, + ); + const body = await res.json(); + expect(body.totals.sessions).toBe(1); // only A's completed Kurk session + expect(body.totals.worked_seconds).toBe(30); + }); + + it('returns zeros and empty arrays for an empty range', async () => { + const app = createApp(); + const adminTok = await authToken(app, 'report-empty-admin@example.com', 'admin'); + const empty = `from=${new Date('1990-01-01').toISOString()}&to=${new Date('1990-02-01').toISOString()}`; + const res = await app.request(`/api/admin/report?${empty}`, { headers: bearer(adminTok) }); + const body = await res.json(); + expect(body.totals).toEqual({ worked_seconds: 0, paused_seconds: 0, pairs: 0, sessions: 0 }); + expect(body.by_worker).toEqual([]); + expect(body.by_activity).toEqual([]); + expect(body.by_type).toEqual([]); + }); +}); diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index e6e4fc6..c61f75d 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -110,3 +110,37 @@ export const AdminUpdateSessionInput = z.object({ status: SessionStatus, }); export type AdminUpdateSessionInput = z.infer; + +export const ReportTotals = z.object({ + worked_seconds: z.number().int(), + paused_seconds: z.number().int(), + pairs: z.number().int(), + sessions: z.number().int(), +}); +export type ReportTotals = z.infer; + +export const ReportWorkerRow = ReportTotals.extend({ + user_id: z.string(), + user_name: z.string(), +}); +export type ReportWorkerRow = z.infer; + +export const ReportActivityRow = ReportTotals.extend({ + activity_id: z.number().int(), + activity_name: z.string(), +}); +export type ReportActivityRow = z.infer; + +export const ReportTypeRow = ReportTotals.extend({ + insole_type: z.string(), +}); +export type ReportTypeRow = z.infer; + +export const ReportResponse = z.object({ + range: z.object({ from: z.string(), to: z.string() }), + totals: ReportTotals, + by_worker: z.array(ReportWorkerRow), + by_activity: z.array(ReportActivityRow), + by_type: z.array(ReportTypeRow), +}); +export type ReportResponse = z.infer;