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,6 +1,15 @@
import { describe, it, expect } from 'vitest';
import { createApp } from '../src/app';
import { authToken, bearer, seedActivity } from './helpers';
import { authToken, bearer, createTestUser, seedActivity } from './helpers';
import { db } from '../src/db/client';
import { user } from '../src/db/schema';
import { eq } from 'drizzle-orm';
async function userIdByEmail(email: string): Promise<string> {
const [row] = await db.select({ id: user.id }).from(user).where(eq(user.email, email));
if (!row) throw new Error(`no user for ${email}`);
return row.id;
}
describe('admin session views', () => {
it('401s without a token', async () => {
@@ -44,3 +53,400 @@ describe('admin session views', () => {
expect(activeBody.some((s: { id: number }) => s.id === started.id)).toBe(true);
});
});
describe('admin users roster', () => {
it('returns id/name/email objects for an admin, ordered by name', async () => {
const app = createApp();
const adminTok = await authToken(app, 'roster-admin@example.com', 'admin');
await createTestUser('roster-w1@example.com');
const res = await app.request('/api/admin/users', { headers: bearer(adminTok) });
expect(res.status).toBe(200);
const body = await res.json();
expect(Array.isArray(body)).toBe(true);
const found = body.find((u: { email: string }) => u.email === 'roster-w1@example.com');
expect(found).toBeTruthy();
expect(found).toHaveProperty('id');
expect(found).toHaveProperty('name');
expect(found).toHaveProperty('email');
// ordered by name ascending
const names = body.map((u: { name: string }) => u.name);
const sorted = [...names].sort();
expect(names).toEqual(sorted);
});
it('403s for a worker', async () => {
const app = createApp();
const workerTok = await authToken(app, 'roster-worker@example.com'); // worker
expect((await app.request('/api/admin/users', { headers: bearer(workerTok) })).status).toBe(
403,
);
});
});
describe('admin manual session create', () => {
it('creates a completed manual session with duration excluding paused', async () => {
const app = createApp();
const adminTok = await authToken(app, 'create-admin@example.com', 'admin');
await createTestUser('create-target@example.com');
const targetId = await userIdByEmail('create-target@example.com');
const activityId = await seedActivity('Snijden');
const start = new Date('2026-06-17T08:00:00.000Z');
const end = new Date('2026-06-17T09:00:00.000Z'); // 1h apart = 3600s
const res = await app.request('/api/admin/sessions', {
method: 'POST',
headers: bearer(adminTok),
body: JSON.stringify({
user_id: targetId,
activity_id: activityId,
insole_type: 'Kurk',
pair_count: 3,
start_time: start.toISOString(),
end_time: end.toISOString(),
paused_seconds: 600,
notes: 'handmatig',
}),
});
expect(res.status).toBe(200);
const body = await res.json();
expect(body.source).toBe('manual');
expect(body.status).toBe('completed');
expect(body.duration_seconds).toBe(3000); // 3600 - 600
expect(body.paused_seconds).toBe(600);
expect(body.paused_at).toBeNull();
expect(body.user_id).toBe(targetId);
expect(body.activity_name).toBe('Snijden');
});
it('400s when end < start', async () => {
const app = createApp();
const adminTok = await authToken(app, 'create-badtime-admin@example.com', 'admin');
await createTestUser('create-badtime-target@example.com');
const targetId = await userIdByEmail('create-badtime-target@example.com');
const activityId = await seedActivity('Frezen');
const res = await app.request('/api/admin/sessions', {
method: 'POST',
headers: bearer(adminTok),
body: JSON.stringify({
user_id: targetId,
activity_id: activityId,
insole_type: 'Kurk',
pair_count: 1,
start_time: '2026-06-17T09:00:00.000Z',
end_time: '2026-06-17T08:00:00.000Z',
}),
});
expect(res.status).toBe(400);
});
it('404s for an unknown user', async () => {
const app = createApp();
const adminTok = await authToken(app, 'create-nouser-admin@example.com', 'admin');
const activityId = await seedActivity('Lijmen');
const res = await app.request('/api/admin/sessions', {
method: 'POST',
headers: bearer(adminTok),
body: JSON.stringify({
user_id: 'does-not-exist',
activity_id: activityId,
insole_type: 'Kurk',
pair_count: 1,
start_time: '2026-06-17T08:00:00.000Z',
end_time: '2026-06-17T09:00:00.000Z',
}),
});
expect(res.status).toBe(404);
});
it('404s for an unknown activity', async () => {
const app = createApp();
const adminTok = await authToken(app, 'create-noact-admin@example.com', 'admin');
await createTestUser('create-noact-target@example.com');
const targetId = await userIdByEmail('create-noact-target@example.com');
const res = await app.request('/api/admin/sessions', {
method: 'POST',
headers: bearer(adminTok),
body: JSON.stringify({
user_id: targetId,
activity_id: 999999,
insole_type: 'Kurk',
pair_count: 1,
start_time: '2026-06-17T08:00:00.000Z',
end_time: '2026-06-17T09:00:00.000Z',
}),
});
expect(res.status).toBe(404);
});
it('403s for a worker', async () => {
const app = createApp();
const workerTok = await authToken(app, 'create-worker@example.com'); // worker
const res = await app.request('/api/admin/sessions', {
method: 'POST',
headers: bearer(workerTok),
body: JSON.stringify({
user_id: 'x',
activity_id: 1,
insole_type: 'Kurk',
pair_count: 1,
start_time: '2026-06-17T08:00:00.000Z',
end_time: '2026-06-17T09:00:00.000Z',
}),
});
expect(res.status).toBe(403);
});
});
describe('admin session edit', () => {
it('recomputes duration on edit', async () => {
const app = createApp();
const adminTok = await authToken(app, 'edit-admin@example.com', 'admin');
await createTestUser('edit-target@example.com');
const targetId = await userIdByEmail('edit-target@example.com');
const activityId = await seedActivity('Polijsten');
const created = await (
await app.request('/api/admin/sessions', {
method: 'POST',
headers: bearer(adminTok),
body: JSON.stringify({
user_id: targetId,
activity_id: activityId,
insole_type: 'Kurk',
pair_count: 2,
start_time: '2026-06-17T08:00:00.000Z',
end_time: '2026-06-17T09:00:00.000Z',
paused_seconds: 0,
}),
})
).json();
expect(created.duration_seconds).toBe(3600);
const res = await app.request(`/api/admin/sessions/${created.id}`, {
method: 'PUT',
headers: bearer(adminTok),
body: JSON.stringify({
activity_id: activityId,
insole_type: 'Berk',
pair_count: 4,
start_time: '2026-06-17T08:00:00.000Z',
end_time: '2026-06-17T10:00:00.000Z', // now 2h
paused_seconds: 300,
notes: 'gecorrigeerd',
status: 'completed',
}),
});
expect(res.status).toBe(200);
const body = await res.json();
expect(body.duration_seconds).toBe(7200 - 300);
expect(body.insole_type).toBe('Berk');
expect(body.pair_count).toBe(4);
expect(body.notes).toBe('gecorrigeerd');
});
it('sets duration null when end_time is null (active)', async () => {
const app = createApp();
const adminTok = await authToken(app, 'edit-active-admin@example.com', 'admin');
await createTestUser('edit-active-target@example.com');
const targetId = await userIdByEmail('edit-active-target@example.com');
const activityId = await seedActivity('Stikken');
const created = await (
await app.request('/api/admin/sessions', {
method: 'POST',
headers: bearer(adminTok),
body: JSON.stringify({
user_id: targetId,
activity_id: activityId,
insole_type: 'Kurk',
pair_count: 2,
start_time: '2026-06-17T08:00:00.000Z',
end_time: '2026-06-17T09:00:00.000Z',
}),
})
).json();
const res = await app.request(`/api/admin/sessions/${created.id}`, {
method: 'PUT',
headers: bearer(adminTok),
body: JSON.stringify({
activity_id: activityId,
insole_type: 'Kurk',
pair_count: 2,
start_time: '2026-06-17T08:00:00.000Z',
end_time: null,
paused_seconds: 0,
notes: null,
status: 'active',
}),
});
expect(res.status).toBe(200);
const body = await res.json();
expect(body.duration_seconds).toBeNull();
expect(body.end_time).toBeNull();
expect(body.status).toBe('active');
});
it('400s when end < start on edit', async () => {
const app = createApp();
const adminTok = await authToken(app, 'edit-badtime-admin@example.com', 'admin');
await createTestUser('edit-badtime-target@example.com');
const targetId = await userIdByEmail('edit-badtime-target@example.com');
const activityId = await seedActivity('Wassen');
const created = await (
await app.request('/api/admin/sessions', {
method: 'POST',
headers: bearer(adminTok),
body: JSON.stringify({
user_id: targetId,
activity_id: activityId,
insole_type: 'Kurk',
pair_count: 2,
start_time: '2026-06-17T08:00:00.000Z',
end_time: '2026-06-17T09:00:00.000Z',
}),
})
).json();
const res = await app.request(`/api/admin/sessions/${created.id}`, {
method: 'PUT',
headers: bearer(adminTok),
body: JSON.stringify({
activity_id: activityId,
insole_type: 'Kurk',
pair_count: 2,
start_time: '2026-06-17T09:00:00.000Z',
end_time: '2026-06-17T08:00:00.000Z',
paused_seconds: 0,
notes: null,
status: 'completed',
}),
});
expect(res.status).toBe(400);
});
it('404s for an unknown session', async () => {
const app = createApp();
const adminTok = await authToken(app, 'edit-404-admin@example.com', 'admin');
const activityId = await seedActivity('Drogen');
const res = await app.request('/api/admin/sessions/999999', {
method: 'PUT',
headers: bearer(adminTok),
body: JSON.stringify({
activity_id: activityId,
insole_type: 'Kurk',
pair_count: 2,
start_time: '2026-06-17T08:00:00.000Z',
end_time: '2026-06-17T09:00:00.000Z',
paused_seconds: 0,
notes: null,
status: 'completed',
}),
});
expect(res.status).toBe(404);
});
});
describe('admin stop/discard another user session', () => {
it("stops another worker's active session and computes duration", async () => {
const app = createApp();
const adminTok = await authToken(app, 'stop-admin@example.com', 'admin');
const workerTok = await authToken(app, 'stop-worker@example.com'); // worker
const activityId = await seedActivity('Frezen');
const started = await (
await app.request('/api/sessions/start', {
method: 'POST',
headers: bearer(workerTok),
body: JSON.stringify({ activity_id: activityId, insole_type: 'Kurk', pair_count: 2 }),
})
).json();
const res = await app.request(`/api/admin/sessions/${started.id}/stop`, {
method: 'POST',
headers: bearer(adminTok),
});
expect(res.status).toBe(200);
const body = await res.json();
expect(body.status).toBe('completed');
expect(body.end_time).not.toBeNull();
expect(body.duration_seconds).toBeGreaterThanOrEqual(0);
expect(body.paused_at).toBeNull();
});
it("discards another worker's active session", async () => {
const app = createApp();
const adminTok = await authToken(app, 'discard-admin@example.com', 'admin');
const workerTok = await authToken(app, 'discard-worker@example.com'); // worker
const activityId = await seedActivity('Lijmen');
const started = await (
await app.request('/api/sessions/start', {
method: 'POST',
headers: bearer(workerTok),
body: JSON.stringify({ activity_id: activityId, insole_type: 'Kurk', pair_count: 2 }),
})
).json();
const res = await app.request(`/api/admin/sessions/${started.id}/discard`, {
method: 'POST',
headers: bearer(adminTok),
});
expect(res.status).toBe(200);
const body = await res.json();
expect(body.status).toBe('discarded');
expect(body.end_time).not.toBeNull();
});
it('409s when stopping an already-closed session', async () => {
const app = createApp();
const adminTok = await authToken(app, 'stop-closed-admin@example.com', 'admin');
const workerTok = await authToken(app, 'stop-closed-worker@example.com');
const activityId = await seedActivity('Snijden');
const started = await (
await app.request('/api/sessions/start', {
method: 'POST',
headers: bearer(workerTok),
body: JSON.stringify({ activity_id: activityId, insole_type: 'Kurk', pair_count: 2 }),
})
).json();
await app.request(`/api/admin/sessions/${started.id}/discard`, {
method: 'POST',
headers: bearer(adminTok),
});
const res = await app.request(`/api/admin/sessions/${started.id}/stop`, {
method: 'POST',
headers: bearer(adminTok),
});
expect(res.status).toBe(409);
});
it('403s for a worker hitting stop/discard', async () => {
const app = createApp();
const workerTok = await authToken(app, 'stop-worker-gate@example.com'); // worker
expect(
(
await app.request('/api/admin/sessions/1/stop', {
method: 'POST',
headers: bearer(workerTok),
})
).status,
).toBe(403);
expect(
(
await app.request('/api/admin/sessions/1/discard', {
method: 'POST',
headers: bearer(workerTok),
})
).status,
).toBe(403);
});
});