feat(api): admin report endpoint + ReportResponse contracts
This commit is contained in:
@@ -1,6 +1,6 @@
|
|||||||
import { Hono } from 'hono';
|
import { Hono } from 'hono';
|
||||||
import { asc, desc, eq } from 'drizzle-orm';
|
import { and, asc, desc, eq, gte, lte, type SQL } from 'drizzle-orm';
|
||||||
import { AdminUpdateSessionInput, CreateManualSessionInput } from '@solelog/shared';
|
import { AdminUpdateSessionInput, CreateManualSessionInput, InsoleType } from '@solelog/shared';
|
||||||
import { db } from '../db/client';
|
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';
|
||||||
@@ -10,6 +10,51 @@ 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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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();
|
export const adminRoutes = new Hono();
|
||||||
|
|
||||||
// Gate the whole /api/admin/* surface to admins.
|
// Gate the whole /api/admin/* surface to admins.
|
||||||
@@ -73,6 +118,118 @@ adminRoutes.get('/api/admin/users', async (c) => {
|
|||||||
return c.json(rows);
|
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.
|
// 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));
|
||||||
|
|||||||
135
apps/api/test/report.test.ts
Normal file
135
apps/api/test/report.test.ts
Normal file
@@ -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<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: 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([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -110,3 +110,37 @@ export const AdminUpdateSessionInput = z.object({
|
|||||||
status: SessionStatus,
|
status: SessionStatus,
|
||||||
});
|
});
|
||||||
export type AdminUpdateSessionInput = z.infer<typeof AdminUpdateSessionInput>;
|
export type AdminUpdateSessionInput = z.infer<typeof AdminUpdateSessionInput>;
|
||||||
|
|
||||||
|
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<typeof ReportTotals>;
|
||||||
|
|
||||||
|
export const ReportWorkerRow = ReportTotals.extend({
|
||||||
|
user_id: z.string(),
|
||||||
|
user_name: z.string(),
|
||||||
|
});
|
||||||
|
export type ReportWorkerRow = z.infer<typeof ReportWorkerRow>;
|
||||||
|
|
||||||
|
export const ReportActivityRow = ReportTotals.extend({
|
||||||
|
activity_id: z.number().int(),
|
||||||
|
activity_name: z.string(),
|
||||||
|
});
|
||||||
|
export type ReportActivityRow = z.infer<typeof ReportActivityRow>;
|
||||||
|
|
||||||
|
export const ReportTypeRow = ReportTotals.extend({
|
||||||
|
insole_type: z.string(),
|
||||||
|
});
|
||||||
|
export type ReportTypeRow = z.infer<typeof ReportTypeRow>;
|
||||||
|
|
||||||
|
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<typeof ReportResponse>;
|
||||||
|
|||||||
Reference in New Issue
Block a user