From 974ecb120dd098ccee74c901d1bdb92e34ffce07 Mon Sep 17 00:00:00 2001 From: Bas van Rossem Date: Wed, 17 Jun 2026 20:54:42 +0200 Subject: [PATCH] feat(api): server-authoritative pause/resume + worked-time stop + CSV paused Add user-scoped POST /api/sessions/:id/pause and /resume endpoints, mirroring the stop handler's ownership/lookup and 401/404/409 guards. Pause sets paused_at (status stays active); resume folds the open span into paused_seconds and clears paused_at. Change stop to fold any open pause span into paused_seconds, then set duration_seconds = max(0, round((end-start)/1000) - paused_seconds) so saved duration is worked time, and clear paused_at. Add a "Paused Duration" column to /api/export (after "Total Duration") using formatDuration(paused_seconds). Products affected: SoleLog backend (apps/api) --- apps/api/src/routes/sessions.ts | 65 ++++++++++++- apps/api/test/export.test.ts | 23 ++++- apps/api/test/sessions.test.ts | 158 ++++++++++++++++++++++++++++++++ 3 files changed, 240 insertions(+), 6 deletions(-) diff --git a/apps/api/src/routes/sessions.ts b/apps/api/src/routes/sessions.ts index 5cbaa90..f08369a 100644 --- a/apps/api/src/routes/sessions.ts +++ b/apps/api/src/routes/sessions.ts @@ -27,6 +27,7 @@ sessionsRoutes.get('/api/export', async (c) => { 'No. of Insoles', 'Date', 'Total Duration', + 'Paused Duration', 'Start Time', 'End Time', ] @@ -43,6 +44,7 @@ sessionsRoutes.get('/api/export', async (c) => { session.pairCount ?? 2, start.toLocaleDateString('nl-BE', { day: '2-digit', month: '2-digit', year: 'numeric' }), formatDuration(session.durationSeconds ?? 0), + formatDuration(session.pausedSeconds ?? 0), start.toLocaleTimeString('nl-BE', { hour: '2-digit', minute: '2-digit', @@ -122,6 +124,55 @@ sessionsRoutes.post('/api/sessions/start', async (c) => { return c.json(toWorkSession(row, { activityName: activity.name })); }); +sessionsRoutes.post('/api/sessions/:id/pause', async (c) => { + const sessionUser = await getSessionUser(c); + if (!sessionUser) return c.json({ error: 'Unauthorized' }, 401); + + 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(and(eq(workSessions.id, id), eq(workSessions.userId, sessionUser.id))); + if (!row) return c.json({ error: 'Session not found' }, 404); + if (row.status !== 'active') return c.json({ error: 'Session not active' }, 409); + if (row.pausedAt) return c.json({ error: 'Already paused' }, 409); + + const [updated] = await db + .update(workSessions) + .set({ pausedAt: new Date() }) + .where(eq(workSessions.id, id)) + .returning(); + return c.json(toWorkSession(updated)); +}); + +sessionsRoutes.post('/api/sessions/:id/resume', async (c) => { + const sessionUser = await getSessionUser(c); + if (!sessionUser) return c.json({ error: 'Unauthorized' }, 401); + + 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(and(eq(workSessions.id, id), eq(workSessions.userId, sessionUser.id))); + if (!row) return c.json({ error: 'Session not found' }, 404); + if (row.status !== 'active') return c.json({ error: 'Session not active' }, 409); + if (!row.pausedAt) return c.json({ error: 'Not paused' }, 409); + + const pausedSeconds = + (row.pausedSeconds ?? 0) + Math.round((Date.now() - new Date(row.pausedAt).getTime()) / 1000); + + const [updated] = await db + .update(workSessions) + .set({ pausedSeconds, pausedAt: null }) + .where(eq(workSessions.id, id)) + .returning(); + return c.json(toWorkSession(updated)); +}); + sessionsRoutes.post('/api/sessions/:id/stop', async (c) => { const sessionUser = await getSessionUser(c); if (!sessionUser) return c.json({ error: 'Unauthorized' }, 401); @@ -136,14 +187,18 @@ sessionsRoutes.post('/api/sessions/:id/stop', async (c) => { if (!row) return c.json({ error: 'Session not found' }, 404); if (row.status !== 'active') return c.json({ error: 'Session already closed' }, 409); - const endTime = new Date(); - const durationSeconds = Math.round( - (endTime.getTime() - new Date(row.startTime).getTime()) / 1000 - ); + 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 endTime = new Date(now); + const wall = Math.round((now - new Date(row.startTime).getTime()) / 1000); + const durationSeconds = Math.max(0, wall - pausedSeconds); const [updated] = await db .update(workSessions) - .set({ endTime, durationSeconds, status: 'completed' }) + .set({ endTime, durationSeconds, pausedSeconds, pausedAt: null, status: 'completed' }) .where(eq(workSessions.id, id)) .returning(); return c.json(toWorkSession(updated)); diff --git a/apps/api/test/export.test.ts b/apps/api/test/export.test.ts index 8e3bed6..947ce92 100644 --- a/apps/api/test/export.test.ts +++ b/apps/api/test/export.test.ts @@ -53,7 +53,7 @@ describe('csv export', () => { const text = await res.text(); const lines = text.split('\n'); expect(lines[0]).toBe( - '"ID","Task","Insole Type","No. of Insoles","Date","Total Duration","Start Time","End Time"' + '"ID","Task","Insole Type","No. of Insoles","Date","Total Duration","Paused Duration","Start Time","End Time"' ); expect(lines).toHaveLength(2); expect(lines[1]).toContain('"Frezen"'); @@ -61,6 +61,27 @@ describe('csv export', () => { expect(lines[1]).toContain('"00:01:30"'); }); + it('includes a Paused Duration column carrying the formatted paused value', async () => { + const app = createApp(); + const token = await authToken(app, 'export-paused@example.com'); + const activityId = await seedActivity('Bekleden'); + const id = await completedSession(app, token, activityId, 'Kurk', 90); + + // Stamp a known paused total on the completed session. + await db.update(workSessions).set({ pausedSeconds: 75 }).where(eq(workSessions.id, id)); + + const res = await app.request('/api/export', { headers: bearer(token) }); + expect(res.status).toBe(200); + const lines = (await res.text()).split('\n'); + const header = lines[0].split(','); + const totalIdx = header.indexOf('"Total Duration"'); + const pausedIdx = header.indexOf('"Paused Duration"'); + expect(pausedIdx).toBe(totalIdx + 1); + + const cells = lines[1].split(','); + expect(cells[pausedIdx]).toBe('"00:01:15"'); + }); + it('excludes active and discarded sessions and scopes to the user', async () => { const app = createApp(); const tokenA = await authToken(app, 'export-scopeA@example.com'); diff --git a/apps/api/test/sessions.test.ts b/apps/api/test/sessions.test.ts index 6955d71..6decbde 100644 --- a/apps/api/test/sessions.test.ts +++ b/apps/api/test/sessions.test.ts @@ -149,6 +149,164 @@ describe('session lifecycle', () => { expect(body.duration_seconds).toBeNull(); }); + it('401s pause/resume without a token', async () => { + const app = createApp(); + + const pause = await app.request('/api/sessions/1/pause', { method: 'POST' }); + expect(pause.status).toBe(401); + + const resume = await app.request('/api/sessions/1/resume', { method: 'POST' }); + expect(resume.status).toBe(401); + }); + + it('404s pause/resume for a missing session', async () => { + const app = createApp(); + const token = await authToken(app, 'sess-pause-missing@example.com'); + + const pause = await app.request('/api/sessions/999999/pause', { + method: 'POST', + headers: bearer(token), + }); + expect(pause.status).toBe(404); + + const resume = await app.request('/api/sessions/999999/resume', { + method: 'POST', + headers: bearer(token), + }); + expect(resume.status).toBe(404); + }); + + it('pauses an active session and 409s a double pause', async () => { + const app = createApp(); + const token = await authToken(app, 'sess-pause@example.com'); + const activityId = await seedActivity('Frezen'); + + const startRes = await app.request('/api/sessions/start', { + method: 'POST', + headers: bearer(token), + body: JSON.stringify({ activity_id: activityId, insole_type: 'Kurk', pair_count: 2 }), + }); + const started = await startRes.json(); + + const pause = await app.request(`/api/sessions/${started.id}/pause`, { + method: 'POST', + headers: bearer(token), + }); + expect(pause.status).toBe(200); + const paused = await pause.json(); + expect(paused.status).toBe('active'); + expect(paused.paused_at).not.toBeNull(); + + const again = await app.request(`/api/sessions/${started.id}/pause`, { + method: 'POST', + headers: bearer(token), + }); + expect(again.status).toBe(409); + }); + + it('resumes a paused session, accumulating paused_seconds, and 409s a running resume', async () => { + const app = createApp(); + const token = await authToken(app, 'sess-resume@example.com'); + const activityId = await seedActivity('Slijpen'); + + const startRes = await app.request('/api/sessions/start', { + method: 'POST', + headers: bearer(token), + body: JSON.stringify({ activity_id: activityId, insole_type: 'Kurk', pair_count: 2 }), + }); + const started = await startRes.json(); + + // Resuming a running session is a 409. + const runningResume = await app.request(`/api/sessions/${started.id}/resume`, { + method: 'POST', + headers: bearer(token), + }); + expect(runningResume.status).toBe(409); + + await app.request(`/api/sessions/${started.id}/pause`, { + method: 'POST', + headers: bearer(token), + }); + + // Backdate the pause start by 3s so resume accumulates a positive span. + await db + .update(workSessions) + .set({ pausedAt: new Date(Date.now() - 3000) }) + .where(eq(workSessions.id, started.id)); + + const resume = await app.request(`/api/sessions/${started.id}/resume`, { + method: 'POST', + headers: bearer(token), + }); + expect(resume.status).toBe(200); + const resumed = await resume.json(); + expect(resumed.paused_at).toBeNull(); + expect(resumed.status).toBe('active'); + expect(resumed.paused_seconds).toBeGreaterThan(0); + }); + + it('stop folds an open pause span and excludes paused time from worked duration', async () => { + const app = createApp(); + const token = await authToken(app, 'sess-stop-paused@example.com'); + const activityId = await seedActivity('Bekleden'); + + const startRes = await app.request('/api/sessions/start', { + method: 'POST', + headers: bearer(token), + body: JSON.stringify({ activity_id: activityId, insole_type: 'Kurk', pair_count: 2 }), + }); + const started = await startRes.json(); + + // The session has run for 10s wall-clock and has been paused for the last 4s + // (open span — paused_at still set at stop time). + await db + .update(workSessions) + .set({ startTime: new Date(Date.now() - 10000), pausedAt: new Date(Date.now() - 4000) }) + .where(eq(workSessions.id, started.id)); + + const res = await app.request(`/api/sessions/${started.id}/stop`, { + method: 'POST', + headers: bearer(token), + }); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.status).toBe('completed'); + expect(body.paused_at).toBeNull(); + // Robust relationships rather than exact seconds. + expect(body.paused_seconds).toBeGreaterThan(0); + expect(body.duration_seconds).toBeGreaterThanOrEqual(0); + expect(body.duration_seconds).toBeLessThan(10); + const wall = Math.round((Date.parse(body.end_time) - Date.parse(body.start_time)) / 1000); + expect(body.duration_seconds + body.paused_seconds).toBe(wall); + }); + + it('stop on a never-paused session keeps paused_seconds at 0 and duration = wall-clock', async () => { + const app = createApp(); + const token = await authToken(app, 'sess-stop-nopause@example.com'); + const activityId = await seedActivity('Afwerken'); + + const startRes = await app.request('/api/sessions/start', { + method: 'POST', + headers: bearer(token), + body: JSON.stringify({ activity_id: activityId, insole_type: 'Kurk', pair_count: 2 }), + }); + const started = await startRes.json(); + + await db + .update(workSessions) + .set({ startTime: new Date(Date.now() - 5000) }) + .where(eq(workSessions.id, started.id)); + + const res = await app.request(`/api/sessions/${started.id}/stop`, { + method: 'POST', + headers: bearer(token), + }); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.paused_seconds).toBe(0); + expect(body.duration_seconds).toBe(5); + }); + it("does not let user B stop user A's session", async () => { const app = createApp(); const tokenA = await authToken(app, 'sess-ownerA@example.com');