feat(api): admin all-users filtered CSV export
This commit is contained in:
@@ -5,6 +5,7 @@ import { db } from '../db/client';
|
|||||||
import { activities, user, workSessions } from '../db/schema';
|
import { activities, user, workSessions } from '../db/schema';
|
||||||
import { getSessionUser, isAdmin } from '../lib/require-user';
|
import { getSessionUser, isAdmin } from '../lib/require-user';
|
||||||
import { toWorkSession } from '../lib/work-session';
|
import { toWorkSession } from '../lib/work-session';
|
||||||
|
import { buildSessionsCsv } from '../lib/csv';
|
||||||
|
|
||||||
function computeDuration(startMs: number, endMs: number, paused: number): number {
|
function computeDuration(startMs: number, endMs: number, paused: number): number {
|
||||||
return Math.max(0, Math.round((endMs - startMs) / 1000) - paused);
|
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.
|
// Manual create → always completed, source='manual', duration derived server-side.
|
||||||
adminRoutes.post('/api/admin/sessions', async (c) => {
|
adminRoutes.post('/api/admin/sessions', async (c) => {
|
||||||
const parsed = CreateManualSessionInput.safeParse(await c.req.json().catch(() => null));
|
const parsed = CreateManualSessionInput.safeParse(await c.req.json().catch(() => null));
|
||||||
|
|||||||
@@ -133,3 +133,58 @@ describe('GET /api/admin/report', () => {
|
|||||||
expect(body.by_type).toEqual([]);
|
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"');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user