feat(api): admin manual-session create/edit/stop/discard + users roster

Sub
Phase 3b.1 needs admin-side write paths so an admin can hand-create a
session for a worker, correct any session, and stop/cancel a worker's
stuck active session — duration always derived server-side.

Adds under the existing /api/admin/* admin guard:
- GET /api/admin/users — {id,name,email} roster ordered by name (worker picker)
- POST /api/admin/sessions — manual create -> completed, source=manual,
  duration = max(0, round((end-start)/1000) - paused)
- PUT /api/admin/sessions/:id — edit any session (no user reassignment);
  recomputes duration when end_time present, else duration null
- POST /api/admin/sessions/:id/stop — fold open pause, end=now, completed
- POST /api/admin/sessions/:id/discard — status=discarded, end=now

Validates end>=start, pair_count>=1, paused<=span, activity/user exist
(400/404); stop/discard on an already-closed session -> 409. Responses
reuse toWorkSession.

Products affected: SoleLog backend (apps/api)
This commit is contained in:
Bas van Rossem
2026-06-17 22:32:58 +02:00
parent 7d1e816fe2
commit 53c66ee144
2 changed files with 554 additions and 2 deletions

View File

@@ -1,10 +1,15 @@
import { Hono } from 'hono';
import { desc, eq } from 'drizzle-orm';
import { asc, desc, eq } from 'drizzle-orm';
import { AdminUpdateSessionInput, CreateManualSessionInput } from '@solelog/shared';
import { db } from '../db/client';
import { activities, user, workSessions } from '../db/schema';
import { getSessionUser, isAdmin } from '../lib/require-user';
import { toWorkSession } from '../lib/work-session';
function computeDuration(startMs: number, endMs: number, paused: number): number {
return Math.max(0, Math.round((endMs - startMs) / 1000) - paused);
}
export const adminRoutes = new Hono();
// Gate the whole /api/admin/* surface to admins.
@@ -58,3 +63,144 @@ adminRoutes.get('/api/admin/sessions/active', async (c) => {
),
);
});
// Roster for the create-form worker picker. Direct DB read — no better-auth client dependency.
adminRoutes.get('/api/admin/users', async (c) => {
const rows = await db
.select({ id: user.id, name: user.name, email: user.email })
.from(user)
.orderBy(asc(user.name));
return c.json(rows);
});
// 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));
if (!parsed.success) return c.json({ error: 'Invalid input' }, 400);
const d = parsed.data;
const start = new Date(d.start_time);
const end = new Date(d.end_time);
if (Number.isNaN(start.getTime()) || Number.isNaN(end.getTime()) || end < start)
return c.json({ error: 'Invalid input' }, 400);
const span = Math.round((end.getTime() - start.getTime()) / 1000);
if (d.paused_seconds > span) return c.json({ error: 'Invalid input' }, 400);
const [u] = await db.select({ id: user.id }).from(user).where(eq(user.id, d.user_id));
if (!u) return c.json({ error: 'User not found' }, 404);
const [act] = await db.select().from(activities).where(eq(activities.id, d.activity_id));
if (!act) return c.json({ error: 'Activity not found' }, 404);
const [row] = await db
.insert(workSessions)
.values({
userId: d.user_id,
activityId: d.activity_id,
insoleType: d.insole_type,
pairCount: d.pair_count,
startTime: start,
endTime: end,
durationSeconds: computeDuration(start.getTime(), end.getTime(), d.paused_seconds),
pausedSeconds: d.paused_seconds,
pausedAt: null,
status: 'completed',
source: 'manual',
notes: d.notes ?? null,
})
.returning();
return c.json(toWorkSession(row, { activityName: act.name }));
});
// Edit any session (no user reassignment). Duration recomputed when end present, else null.
adminRoutes.put('/api/admin/sessions/:id', async (c) => {
const id = Number.parseInt(c.req.param('id'), 10);
if (Number.isNaN(id)) return c.json({ error: 'Session not found' }, 404);
const parsed = AdminUpdateSessionInput.safeParse(await c.req.json().catch(() => null));
if (!parsed.success) return c.json({ error: 'Invalid input' }, 400);
const d = parsed.data;
const [row] = await db.select().from(workSessions).where(eq(workSessions.id, id));
if (!row) return c.json({ error: 'Session not found' }, 404);
const start = new Date(d.start_time);
if (Number.isNaN(start.getTime())) return c.json({ error: 'Invalid input' }, 400);
let endTime: Date | null = null;
let durationSeconds: number | null = null;
if (d.end_time !== null) {
const end = new Date(d.end_time);
if (Number.isNaN(end.getTime()) || end < start) return c.json({ error: 'Invalid input' }, 400);
const span = Math.round((end.getTime() - start.getTime()) / 1000);
if (d.paused_seconds > span) return c.json({ error: 'Invalid input' }, 400);
endTime = end;
durationSeconds = computeDuration(start.getTime(), end.getTime(), d.paused_seconds);
}
const [act] = await db.select().from(activities).where(eq(activities.id, d.activity_id));
if (!act) return c.json({ error: 'Activity not found' }, 404);
const [updated] = await db
.update(workSessions)
.set({
activityId: d.activity_id,
insoleType: d.insole_type,
pairCount: d.pair_count,
startTime: start,
endTime,
durationSeconds,
pausedSeconds: d.paused_seconds,
pausedAt: null,
status: d.status,
notes: d.notes,
})
.where(eq(workSessions.id, id))
.returning();
return c.json(toWorkSession(updated, { activityName: act.name }));
});
// Quick stop of an active session: fold any open pause, end=now, completed.
adminRoutes.post('/api/admin/sessions/:id/stop', async (c) => {
const id = Number.parseInt(c.req.param('id'), 10);
if (Number.isNaN(id)) return c.json({ error: 'Session not found' }, 404);
const [row] = await db.select().from(workSessions).where(eq(workSessions.id, id));
if (!row) return c.json({ error: 'Session not found' }, 404);
if (row.status !== 'active') return c.json({ error: 'Session already closed' }, 409);
const now = Date.now();
const extraPaused = row.pausedAt
? Math.round((now - new Date(row.pausedAt).getTime()) / 1000)
: 0;
const pausedSeconds = (row.pausedSeconds ?? 0) + extraPaused;
const durationSeconds = computeDuration(new Date(row.startTime).getTime(), now, pausedSeconds);
const [updated] = await db
.update(workSessions)
.set({
endTime: new Date(now),
durationSeconds,
pausedSeconds,
pausedAt: null,
status: 'completed',
})
.where(eq(workSessions.id, id))
.returning();
return c.json(toWorkSession(updated));
});
// Cancel an active session: status='discarded', end=now (no hard delete).
adminRoutes.post('/api/admin/sessions/:id/discard', async (c) => {
const id = Number.parseInt(c.req.param('id'), 10);
if (Number.isNaN(id)) return c.json({ error: 'Session not found' }, 404);
const [row] = await db.select().from(workSessions).where(eq(workSessions.id, id));
if (!row) return c.json({ error: 'Session not found' }, 404);
if (row.status !== 'active') return c.json({ error: 'Session already closed' }, 409);
const [updated] = await db
.update(workSessions)
.set({ status: 'discarded', endTime: new Date() })
.where(eq(workSessions.id, id))
.returning();
return c.json(toWorkSession(updated));
});