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:
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user