diff --git a/apps/api/src/routes/sessions.ts b/apps/api/src/routes/sessions.ts index 4682b4b..30c62cf 100644 --- a/apps/api/src/routes/sessions.ts +++ b/apps/api/src/routes/sessions.ts @@ -1,5 +1,5 @@ import { Hono } from 'hono'; -import { and, eq } from 'drizzle-orm'; +import { and, desc, eq } from 'drizzle-orm'; import { StartSessionInput } from '@solelog/shared'; import type { WorkSession } from '@solelog/shared'; import { db } from '../db/client'; @@ -28,6 +28,34 @@ function toWorkSession(row: WorkSessionRow, activityName?: string | null): WorkS }; } +sessionsRoutes.get('/api/sessions/active', async (c) => { + const sessionUser = await getSessionUser(c); + if (!sessionUser) return c.json({ error: 'Unauthorized' }, 401); + + const rows = await db + .select({ session: workSessions, activityName: activities.name }) + .from(workSessions) + .leftJoin(activities, eq(workSessions.activityId, activities.id)) + .where(and(eq(workSessions.userId, sessionUser.id), eq(workSessions.status, 'active'))) + .orderBy(desc(workSessions.startTime)); + + return c.json(rows.map((r) => toWorkSession(r.session, r.activityName))); +}); + +sessionsRoutes.get('/api/sessions', async (c) => { + const sessionUser = await getSessionUser(c); + if (!sessionUser) return c.json({ error: 'Unauthorized' }, 401); + + const rows = await db + .select({ session: workSessions, activityName: activities.name }) + .from(workSessions) + .leftJoin(activities, eq(workSessions.activityId, activities.id)) + .where(eq(workSessions.userId, sessionUser.id)) + .orderBy(desc(workSessions.startTime)); + + return c.json(rows.map((r) => toWorkSession(r.session, r.activityName))); +}); + sessionsRoutes.post('/api/sessions/start', async (c) => { const sessionUser = await getSessionUser(c); if (!sessionUser) return c.json({ error: 'Unauthorized' }, 401); diff --git a/apps/api/test/sessions.test.ts b/apps/api/test/sessions.test.ts index 4e36c4e..9303ddd 100644 --- a/apps/api/test/sessions.test.ts +++ b/apps/api/test/sessions.test.ts @@ -207,3 +207,108 @@ describe('session lifecycle', () => { expect(row.endTime).toBeNull(); }); }); + +describe('session reads', () => { + it('401s GET /api/sessions and /api/sessions/active without a token', async () => { + const app = createApp(); + + const history = await app.request('/api/sessions'); + expect(history.status).toBe(401); + + const active = await app.request('/api/sessions/active'); + expect(active.status).toBe(401); + }); + + it("returns the user's sessions joined with activity name, newest first", async () => { + const app = createApp(); + const token = await authToken(app, 'reads-history@example.com'); + const frezenId = await createActivity(app, token, 'Frezen'); + const slijpenId = await createActivity(app, token, 'Slijpen'); + + const firstRes = await app.request('/api/sessions/start', { + method: 'POST', + headers: bearer(token), + body: JSON.stringify({ activity_id: frezenId, insole_type: 'Kurk', pair_count: 2 }), + }); + const first = await firstRes.json(); + const secondRes = await app.request('/api/sessions/start', { + method: 'POST', + headers: bearer(token), + body: JSON.stringify({ activity_id: slijpenId, insole_type: 'Berk', pair_count: 2 }), + }); + const second = await secondRes.json(); + + // Control ordering: make the Slijpen session clearly newer. + await db + .update(workSessions) + .set({ startTime: new Date(Date.now() - 10000) }) + .where(eq(workSessions.id, first.id)); + await db + .update(workSessions) + .set({ startTime: new Date(Date.now() - 1000) }) + .where(eq(workSessions.id, second.id)); + + const res = await app.request('/api/sessions', { headers: bearer(token) }); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body).toHaveLength(2); + expect(new Date(body[0].start_time).getTime()).toBeGreaterThan( + new Date(body[1].start_time).getTime() + ); + expect(body[0].activity_name).toBe('Slijpen'); + expect(body[1].activity_name).toBe('Frezen'); + }); + + it('scopes history to the requesting user', async () => { + const app = createApp(); + const tokenA = await authToken(app, 'reads-scopeA@example.com'); + const tokenB = await authToken(app, 'reads-scopeB@example.com'); + const activityId = await createActivity(app, tokenA, 'Bekleden'); + + const startRes = await app.request('/api/sessions/start', { + method: 'POST', + headers: bearer(tokenA), + body: JSON.stringify({ activity_id: activityId, insole_type: 'Kurk', pair_count: 2 }), + }); + const aSession = await startRes.json(); + + const res = await app.request('/api/sessions', { headers: bearer(tokenB) }); + expect(res.status).toBe(200); + const body = await res.json(); + const ids = body.map((s: { id: number }) => s.id); + expect(ids).not.toContain(aSession.id); + }); + + it('returns only active sessions from /api/sessions/active', async () => { + const app = createApp(); + const token = await authToken(app, 'reads-active@example.com'); + const activityId = await createActivity(app, token, 'Afwerken'); + + // One session stays active. + const activeRes = await app.request('/api/sessions/start', { + method: 'POST', + headers: bearer(token), + body: JSON.stringify({ activity_id: activityId, insole_type: 'Kurk', pair_count: 2 }), + }); + const activeStarted = await activeRes.json(); + + // Another is started then stopped (completed). + const stoppedRes = await app.request('/api/sessions/start', { + method: 'POST', + headers: bearer(token), + body: JSON.stringify({ activity_id: activityId, insole_type: 'Berk', pair_count: 2 }), + }); + const stoppedStarted = await stoppedRes.json(); + await app.request(`/api/sessions/${stoppedStarted.id}/stop`, { + method: 'POST', + headers: bearer(token), + }); + + const res = await app.request('/api/sessions/active', { headers: bearer(token) }); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body).toHaveLength(1); + expect(body[0].id).toBe(activeStarted.id); + expect(body[0].status).toBe('active'); + }); +});