feat(api): user-scoped CSV export matching legacy format
This commit is contained in:
13
apps/api/src/lib/csv.ts
Normal file
13
apps/api/src/lib/csv.ts
Normal file
@@ -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')}`;
|
||||||
|
}
|
||||||
@@ -1,10 +1,11 @@
|
|||||||
import { Hono } from 'hono';
|
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 { StartSessionInput } from '@solelog/shared';
|
||||||
import type { WorkSession } from '@solelog/shared';
|
import type { WorkSession } from '@solelog/shared';
|
||||||
import { db } from '../db/client';
|
import { db } from '../db/client';
|
||||||
import { activities, workSessions } from '../db/schema';
|
import { activities, workSessions } from '../db/schema';
|
||||||
import { getSessionUser } from '../lib/require-user';
|
import { getSessionUser } from '../lib/require-user';
|
||||||
|
import { quote, formatDuration } from '../lib/csv';
|
||||||
|
|
||||||
export const sessionsRoutes = new Hono();
|
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) => {
|
sessionsRoutes.get('/api/sessions/active', async (c) => {
|
||||||
const sessionUser = await getSessionUser(c);
|
const sessionUser = await getSessionUser(c);
|
||||||
if (!sessionUser) return c.json({ error: 'Unauthorized' }, 401);
|
if (!sessionUser) return c.json({ error: 'Unauthorized' }, 401);
|
||||||
|
|||||||
144
apps/api/test/export.test.ts
Normal file
144
apps/api/test/export.test.ts
Normal file
@@ -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<string> {
|
||||||
|
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<string, string> {
|
||||||
|
return { authorization: `Bearer ${token}`, 'content-type': 'application/json' };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createActivity(app: Hono, token: string, name: string): Promise<number> {
|
||||||
|
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<number> {
|
||||||
|
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');
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user