diff --git a/apps/api/src/lib/csv.ts b/apps/api/src/lib/csv.ts new file mode 100644 index 0000000..67ba557 --- /dev/null +++ b/apps/api/src/lib/csv.ts @@ -0,0 +1,13 @@ +// CSV helpers ported verbatim from the legacy export route (docs/reference/legacy-backend.md ยง4). + +// Wrap a value in double quotes, doubling any embedded double quote (standard CSV escaping). +export const quote = (value: unknown) => `"${String(value).replace(/"/g, '""')}"`; + +// Format a total number of seconds as zero-padded HH:MM:SS (hours can exceed 99). +export function formatDuration(totalSeconds: number): string { + const s = totalSeconds || 0; + const h = Math.floor(s / 3600); + const m = Math.floor((s % 3600) / 60); + const sec = s % 60; + return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}:${String(sec).padStart(2, '0')}`; +} diff --git a/apps/api/src/routes/sessions.ts b/apps/api/src/routes/sessions.ts index 30c62cf..495a649 100644 --- a/apps/api/src/routes/sessions.ts +++ b/apps/api/src/routes/sessions.ts @@ -1,10 +1,11 @@ import { Hono } from 'hono'; -import { and, desc, eq } from 'drizzle-orm'; +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 { quote, formatDuration } from '../lib/csv'; export const sessionsRoutes = new Hono(); @@ -28,6 +29,61 @@ function toWorkSession(row: WorkSessionRow, activityName?: string | null): WorkS }; } +sessionsRoutes.get('/api/export', async (c) => { + const sessionUser = await getSessionUser(c); + if (!sessionUser) return c.json({ error: 'Unauthorized' }, 401); + + const rows = await db + .select({ session: workSessions, activityName: activities.name }) + .from(workSessions) + .leftJoin(activities, eq(workSessions.activityId, activities.id)) + .where(and(eq(workSessions.userId, sessionUser.id), eq(workSessions.status, 'completed'))) + .orderBy(asc(workSessions.startTime)); + + const header = [ + 'ID', + 'Task', + 'Insole Type', + 'No. of Insoles', + 'Date', + 'Total Duration', + 'Start Time', + 'End Time', + ] + .map(quote) + .join(','); + + const dataLines = rows.map(({ session, activityName }) => { + const start = new Date(session.startTime); + const end = session.endTime ? new Date(session.endTime) : null; + return [ + session.id, + activityName ?? '', + session.insoleType ?? 'Kurk', + session.pairCount ?? 2, + start.toLocaleDateString('nl-BE', { day: '2-digit', month: '2-digit', year: 'numeric' }), + formatDuration(session.durationSeconds ?? 0), + start.toLocaleTimeString('nl-BE', { + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + }), + end + ? end.toLocaleTimeString('nl-BE', { hour: '2-digit', minute: '2-digit', second: '2-digit' }) + : '', + ] + .map(quote) + .join(','); + }); + + const csv = [header, ...dataLines].join('\n'); + + return c.body(csv, 200, { + 'Content-Type': 'text/csv; charset=utf-8', + 'Content-Disposition': 'attachment; filename="insole-production-report.csv"', + }); +}); + sessionsRoutes.get('/api/sessions/active', async (c) => { const sessionUser = await getSessionUser(c); if (!sessionUser) return c.json({ error: 'Unauthorized' }, 401); diff --git a/apps/api/test/export.test.ts b/apps/api/test/export.test.ts new file mode 100644 index 0000000..d866f6c --- /dev/null +++ b/apps/api/test/export.test.ts @@ -0,0 +1,144 @@ +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 { quote, formatDuration } from '../src/lib/csv'; + +const json = { 'content-type': 'application/json' }; + +// Sign up + sign in a user, returning the bearer token. +async function authToken(app: Hono, email: string): Promise { + const password = 'sterk-wachtwoord-123'; + await app.request('/api/auth/sign-up/email', { + method: 'POST', + headers: json, + body: JSON.stringify({ email, password, name: email.split('@')[0] }), + }); + const signin = await app.request('/api/auth/sign-in/email', { + method: 'POST', + headers: json, + body: JSON.stringify({ email, password }), + }); + const token = signin.headers.get('set-auth-token'); + if (!token) throw new Error('no token'); + return token; +} + +function bearer(token: string): Record { + return { authorization: `Bearer ${token}`, 'content-type': 'application/json' }; +} + +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; +} + +// Start a session, backdate its start_time by `durationSeconds`, then stop it, +// producing a completed session with an exact duration. +async function completedSession( + app: Hono, + token: string, + activityId: number, + insoleType: string, + durationSeconds: number +): 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: 2 }), + }); + 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('csv export', () => { + it('401s without a token', async () => { + const app = createApp(); + const res = await app.request('/api/export'); + expect(res.status).toBe(401); + }); + + 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'); + await completedSession(app, token, activityId, 'Kurk', 90); + + const res = await app.request('/api/export', { headers: bearer(token) }); + expect(res.status).toBe(200); + expect(res.headers.get('content-type')).toContain('text/csv'); + expect(res.headers.get('content-disposition')).toBe( + 'attachment; filename="insole-production-report.csv"' + ); + + const text = await res.text(); + const lines = text.split('\n'); + expect(lines[0]).toBe( + '"ID","Task","Insole Type","No. of Insoles","Date","Total Duration","Start Time","End Time"' + ); + expect(lines).toHaveLength(2); + expect(lines[1]).toContain('"Frezen"'); + expect(lines[1]).toContain('"Kurk"'); + expect(lines[1]).toContain('"00:01:30"'); + }); + + it('excludes active and discarded sessions and scopes to the user', async () => { + 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'); + + // User A: one completed session. + await completedSession(app, tokenA, activityId, 'Kurk', 30); + + // User A: an active session (left running). + await app.request('/api/sessions/start', { + method: 'POST', + headers: bearer(tokenA), + body: JSON.stringify({ activity_id: activityId, insole_type: 'Berk', pair_count: 2 }), + }); + + // User A: a discarded session. + const discardRes = await app.request('/api/sessions/start', { + method: 'POST', + headers: bearer(tokenA), + body: JSON.stringify({ activity_id: activityId, insole_type: '3D', pair_count: 2 }), + }); + const toDiscard = await discardRes.json(); + await app.request(`/api/sessions/${toDiscard.id}/discard`, { + method: 'POST', + headers: bearer(tokenA), + }); + + // User B: a completed session that must not appear in A's export. + await completedSession(app, tokenB, activityId, 'Kurk', 45); + + const res = await app.request('/api/export', { headers: bearer(tokenA) }); + expect(res.status).toBe(200); + const lines = (await res.text()).split('\n'); + expect(lines).toHaveLength(2); // header + 1 completed row + }); +}); + +describe('csv helpers', () => { + it('quote escapes embedded double quotes', () => { + expect(quote('a"b')).toBe('"a""b"'); + }); + + it('formatDuration formats seconds as HH:MM:SS', () => { + expect(formatDuration(3661)).toBe('01:01:01'); + expect(formatDuration(0)).toBe('00:00:00'); + }); +});