feat(api): admin report endpoint + ReportResponse contracts
This commit is contained in:
@@ -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));
|
||||
|
||||
Reference in New Issue
Block a user