feat(api): admin report endpoint + ReportResponse contracts

This commit is contained in:
Bas van Rossem
2026-06-24 16:25:36 +02:00
parent 01fa18f401
commit 4b213e25a8
3 changed files with 328 additions and 2 deletions

View File

@@ -1,6 +1,6 @@
import { Hono } from 'hono';
import { asc, desc, eq } from 'drizzle-orm';
import { AdminUpdateSessionInput, CreateManualSessionInput } from '@solelog/shared';
import { and, asc, desc, eq, gte, lte, type SQL } from 'drizzle-orm';
import { AdminUpdateSessionInput, CreateManualSessionInput, InsoleType } from '@solelog/shared';
import { db } from '../db/client';
import { activities, user, workSessions } from '../db/schema';
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);
}
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();
// Gate the whole /api/admin/* surface to admins.
@@ -73,6 +118,118 @@ adminRoutes.get('/api/admin/users', async (c) => {
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.
adminRoutes.post('/api/admin/sessions', async (c) => {
const parsed = CreateManualSessionInput.safeParse(await c.req.json().catch(() => null));