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)
This commit is contained in:
@@ -27,6 +27,7 @@ sessionsRoutes.get('/api/export', async (c) => {
|
|||||||
'No. of Insoles',
|
'No. of Insoles',
|
||||||
'Date',
|
'Date',
|
||||||
'Total Duration',
|
'Total Duration',
|
||||||
|
'Paused Duration',
|
||||||
'Start Time',
|
'Start Time',
|
||||||
'End Time',
|
'End Time',
|
||||||
]
|
]
|
||||||
@@ -43,6 +44,7 @@ sessionsRoutes.get('/api/export', async (c) => {
|
|||||||
session.pairCount ?? 2,
|
session.pairCount ?? 2,
|
||||||
start.toLocaleDateString('nl-BE', { day: '2-digit', month: '2-digit', year: 'numeric' }),
|
start.toLocaleDateString('nl-BE', { day: '2-digit', month: '2-digit', year: 'numeric' }),
|
||||||
formatDuration(session.durationSeconds ?? 0),
|
formatDuration(session.durationSeconds ?? 0),
|
||||||
|
formatDuration(session.pausedSeconds ?? 0),
|
||||||
start.toLocaleTimeString('nl-BE', {
|
start.toLocaleTimeString('nl-BE', {
|
||||||
hour: '2-digit',
|
hour: '2-digit',
|
||||||
minute: '2-digit',
|
minute: '2-digit',
|
||||||
@@ -122,6 +124,55 @@ sessionsRoutes.post('/api/sessions/start', async (c) => {
|
|||||||
return c.json(toWorkSession(row, { activityName: activity.name }));
|
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) => {
|
sessionsRoutes.post('/api/sessions/:id/stop', async (c) => {
|
||||||
const sessionUser = await getSessionUser(c);
|
const sessionUser = await getSessionUser(c);
|
||||||
if (!sessionUser) return c.json({ error: 'Unauthorized' }, 401);
|
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) return c.json({ error: 'Session not found' }, 404);
|
||||||
if (row.status !== 'active') return c.json({ error: 'Session already closed' }, 409);
|
if (row.status !== 'active') return c.json({ error: 'Session already closed' }, 409);
|
||||||
|
|
||||||
const endTime = new Date();
|
const now = Date.now();
|
||||||
const durationSeconds = Math.round(
|
const extraPaused = row.pausedAt
|
||||||
(endTime.getTime() - new Date(row.startTime).getTime()) / 1000
|
? 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
|
const [updated] = await db
|
||||||
.update(workSessions)
|
.update(workSessions)
|
||||||
.set({ endTime, durationSeconds, status: 'completed' })
|
.set({ endTime, durationSeconds, pausedSeconds, pausedAt: null, status: 'completed' })
|
||||||
.where(eq(workSessions.id, id))
|
.where(eq(workSessions.id, id))
|
||||||
.returning();
|
.returning();
|
||||||
return c.json(toWorkSession(updated));
|
return c.json(toWorkSession(updated));
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ describe('csv export', () => {
|
|||||||
const text = await res.text();
|
const text = await res.text();
|
||||||
const lines = text.split('\n');
|
const lines = text.split('\n');
|
||||||
expect(lines[0]).toBe(
|
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).toHaveLength(2);
|
||||||
expect(lines[1]).toContain('"Frezen"');
|
expect(lines[1]).toContain('"Frezen"');
|
||||||
@@ -61,6 +61,27 @@ describe('csv export', () => {
|
|||||||
expect(lines[1]).toContain('"00:01:30"');
|
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 () => {
|
it('excludes active and discarded sessions and scopes to the user', async () => {
|
||||||
const app = createApp();
|
const app = createApp();
|
||||||
const tokenA = await authToken(app, 'export-scopeA@example.com');
|
const tokenA = await authToken(app, 'export-scopeA@example.com');
|
||||||
|
|||||||
@@ -149,6 +149,164 @@ describe('session lifecycle', () => {
|
|||||||
expect(body.duration_seconds).toBeNull();
|
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 () => {
|
it("does not let user B stop user A's session", async () => {
|
||||||
const app = createApp();
|
const app = createApp();
|
||||||
const tokenA = await authToken(app, 'sess-ownerA@example.com');
|
const tokenA = await authToken(app, 'sess-ownerA@example.com');
|
||||||
|
|||||||
Reference in New Issue
Block a user