diff --git a/apps/api/src/routes/admin.ts b/apps/api/src/routes/admin.ts index cb6046b..23d3cd2 100644 --- a/apps/api/src/routes/admin.ts +++ b/apps/api/src/routes/admin.ts @@ -5,6 +5,7 @@ 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'; +import { buildSessionsCsv } from '../lib/csv'; function computeDuration(startMs: number, endMs: number, paused: number): number { return Math.max(0, Math.round((endMs - startMs) / 1000) - paused); @@ -230,6 +231,41 @@ adminRoutes.get('/api/admin/report', async (c) => { }); }); +adminRoutes.get('/api/admin/export', 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))) + .orderBy(asc(workSessions.startTime)); + + const csv = buildSessionsCsv( + rows.map((r) => ({ + id: r.session.id, + activityName: r.activityName, + userName: r.userName, + insoleType: r.session.insoleType, + pairCount: r.session.pairCount, + startTime: r.session.startTime, + endTime: r.session.endTime, + durationSeconds: r.session.durationSeconds, + pausedSeconds: r.session.pausedSeconds, + })), + { includeWorker: true }, + ); + + const fromDate = q.from.toISOString().slice(0, 10); + const toDate = q.to.toISOString().slice(0, 10); + return c.body(csv, 200, { + 'Content-Type': 'text/csv; charset=utf-8', + 'Content-Disposition': `attachment; filename="solelog-report_${fromDate}_${toDate}.csv"`, + }); +}); + // 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 index 69396c3..c25beb0 100644 --- a/apps/api/test/report.test.ts +++ b/apps/api/test/report.test.ts @@ -133,3 +133,58 @@ describe('GET /api/admin/report', () => { expect(body.by_type).toEqual([]); }); }); + +describe('GET /api/admin/export', () => { + it('401s without a token and 403s for a worker', async () => { + const app = createApp(); + expect((await app.request(`/api/admin/export?${WIDE}`)).status).toBe(401); + const workerTok = await authToken(app, 'export-admin-worker@example.com'); + const res = await app.request(`/api/admin/export?${WIDE}`, { headers: bearer(workerTok) }); + expect(res.status).toBe(403); + }); + + it('exports all workers with a leading Worker column, range in the filename', async () => { + const app = createApp(); + const adminTok = await authToken(app, 'export-admin@example.com', 'admin'); + const a = await authToken(app, 'export-allusers-a@example.com'); + const b = await authToken(app, 'export-allusers-b@example.com'); + const act = await seedActivity('Frezen'); + await completed(app, a, act, 'Kurk', 90, 2); + await completed(app, b, act, 'Berk', 60, 2); + + // Scope to this test's own activity so the all-users export isolates from the + // sessions other tests seed into the shared, run-scoped DB. + const res = await app.request(`/api/admin/export?${WIDE}&activity_id=${act}`, { + headers: bearer(adminTok), + }); + expect(res.status).toBe(200); + expect(res.headers.get('content-type')).toContain('text/csv'); + expect(res.headers.get('content-disposition')).toMatch( + /attachment; filename="solelog-report_\d{4}-\d{2}-\d{2}_\d{4}-\d{2}-\d{2}\.csv"/, + ); + + const lines = (await res.text()).split('\n'); + expect(lines[0]).toBe( + '"Worker","ID","Task","Insole Type","No. of Insoles","Date","Total Duration","Paused Duration","Start Time","End Time"', + ); + expect(lines).toHaveLength(3); // header + 2 workers' sessions + expect(lines.some((l) => l.includes('export-allusers-a'))).toBe(true); + expect(lines.some((l) => l.includes('export-allusers-b'))).toBe(true); + }); + + it('respects the type filter', async () => { + const app = createApp(); + const adminTok = await authToken(app, 'export-typefilter-admin@example.com', 'admin'); + const a = await authToken(app, 'export-typefilter-a@example.com'); + const act = await seedActivity('Lijmen'); + await completed(app, a, act, 'Kurk', 30, 2); + await completed(app, a, act, 'Berk', 40, 2); + + const res = await app.request(`/api/admin/export?${WIDE}&activity_id=${act}&insole_type=Berk`, { + headers: bearer(adminTok), + }); + const lines = (await res.text()).split('\n'); + expect(lines).toHaveLength(2); // header + only the Berk row + expect(lines[1]).toContain('"Berk"'); + }); +});