diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index dacbd70..dc7f927 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -2,6 +2,7 @@ import { Hono } from 'hono'; import { health } from './routes/health'; import { me } from './routes/me'; import { activitiesRoutes } from './routes/activities'; +import { sessionsRoutes } from './routes/sessions'; import { auth } from './auth'; export function createApp(): Hono { @@ -10,5 +11,6 @@ export function createApp(): Hono { app.on(['POST', 'GET'], '/api/auth/*', (c) => auth.handler(c.req.raw)); app.route('/', me); app.route('/', activitiesRoutes); + app.route('/', sessionsRoutes); return app; } diff --git a/apps/api/src/routes/sessions.ts b/apps/api/src/routes/sessions.ts new file mode 100644 index 0000000..4682b4b --- /dev/null +++ b/apps/api/src/routes/sessions.ts @@ -0,0 +1,106 @@ +import { Hono } from 'hono'; +import { and, eq } from 'drizzle-orm'; +import { StartSessionInput } from '@solelog/shared'; +import type { WorkSession } from '@solelog/shared'; +import { db } from '../db/client'; +import { activities, workSessions } from '../db/schema'; +import { getSessionUser } from '../lib/require-user'; + +export const sessionsRoutes = new Hono(); + +type WorkSessionRow = typeof workSessions.$inferSelect; + +function toWorkSession(row: WorkSessionRow, activityName?: string | null): WorkSession { + return { + id: row.id, + user_id: row.userId, + activity_id: row.activityId, + activity_name: activityName ?? undefined, + insole_type: (row.insoleType ?? null) as WorkSession['insole_type'], + pair_count: row.pairCount, + start_time: new Date(row.startTime).toISOString(), + end_time: row.endTime ? new Date(row.endTime).toISOString() : null, + duration_seconds: row.durationSeconds ?? null, + status: row.status as WorkSession['status'], + source: row.source as WorkSession['source'], + notes: row.notes ?? null, + created_at: new Date(row.createdAt).toISOString(), + }; +} + +sessionsRoutes.post('/api/sessions/start', async (c) => { + const sessionUser = await getSessionUser(c); + if (!sessionUser) return c.json({ error: 'Unauthorized' }, 401); + + const parsed = StartSessionInput.safeParse(await c.req.json().catch(() => null)); + if (!parsed.success) return c.json({ error: 'Invalid input' }, 400); + + const [activity] = await db + .select() + .from(activities) + .where(eq(activities.id, parsed.data.activity_id)); + if (!activity) return c.json({ error: 'Activity not found' }, 404); + + const [row] = await db + .insert(workSessions) + .values({ + userId: sessionUser.id, + activityId: parsed.data.activity_id, + insoleType: parsed.data.insole_type, + pairCount: parsed.data.pair_count, + startTime: new Date(), + endTime: null, + durationSeconds: null, + status: 'active', + source: 'app', + }) + .returning(); + return c.json(toWorkSession(row, activity.name)); +}); + +sessionsRoutes.post('/api/sessions/:id/stop', 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 already closed' }, 409); + + const endTime = new Date(); + const durationSeconds = Math.round((endTime.getTime() - new Date(row.startTime).getTime()) / 1000); + + const [updated] = await db + .update(workSessions) + .set({ endTime, durationSeconds, status: 'completed' }) + .where(eq(workSessions.id, id)) + .returning(); + return c.json(toWorkSession(updated)); +}); + +sessionsRoutes.post('/api/sessions/:id/discard', 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 already closed' }, 409); + + const [updated] = await db + .update(workSessions) + .set({ status: 'discarded', endTime: new Date() }) + .where(eq(workSessions.id, id)) + .returning(); + return c.json(toWorkSession(updated)); +}); diff --git a/apps/api/test/sessions.test.ts b/apps/api/test/sessions.test.ts new file mode 100644 index 0000000..4e36c4e --- /dev/null +++ b/apps/api/test/sessions.test.ts @@ -0,0 +1,209 @@ +import { describe, it, expect } from 'vitest'; +import type { Hono } from 'hono'; +import { createApp } from '../src/app'; +import { db } from '../src/db/client'; +import { workSessions } from '../src/db/schema'; +import { eq } from 'drizzle-orm'; + +const json = { 'content-type': 'application/json' }; + +// Sign up + sign in a user, returning the bearer token. +async function authToken(app: Hono, email: string): Promise { + const password = 'sterk-wachtwoord-123'; + await app.request('/api/auth/sign-up/email', { + method: 'POST', + headers: json, + body: JSON.stringify({ email, password, name: email.split('@')[0] }), + }); + const signin = await app.request('/api/auth/sign-in/email', { + method: 'POST', + headers: json, + body: JSON.stringify({ email, password }), + }); + const token = signin.headers.get('set-auth-token'); + if (!token) throw new Error('no token'); + return token; +} + +function bearer(token: string): Record { + return { authorization: `Bearer ${token}`, 'content-type': 'application/json' }; +} + +// Create an activity via the API and return its id. +async function createActivity(app: Hono, token: string, name: string): Promise { + const res = await app.request('/api/activities', { + method: 'POST', + headers: bearer(token), + body: JSON.stringify({ name, insole_types: ['Kurk', 'Berk', '3D'] }), + }); + const body = await res.json(); + return body.id as number; +} + +describe('session lifecycle', () => { + it('401s start/stop/discard without a token', async () => { + const app = createApp(); + + const start = await app.request('/api/sessions/start', { + method: 'POST', + headers: json, + body: JSON.stringify({ activity_id: 1, insole_type: 'Kurk', pair_count: 2 }), + }); + expect(start.status).toBe(401); + + const stop = await app.request('/api/sessions/1/stop', { method: 'POST' }); + expect(stop.status).toBe(401); + + const discard = await app.request('/api/sessions/1/discard', { method: 'POST' }); + expect(discard.status).toBe(401); + }); + + it('starts an active session', async () => { + const app = createApp(); + const token = await authToken(app, 'sess-start@example.com'); + const activityId = await createActivity(app, token, 'Frezen'); + + const res = await app.request('/api/sessions/start', { + method: 'POST', + headers: bearer(token), + body: JSON.stringify({ activity_id: activityId, insole_type: 'Kurk', pair_count: 2 }), + }); + expect(res.status).toBe(200); + const body = await res.json(); + expect(typeof body.id).toBe('number'); + expect(body.activity_id).toBe(activityId); + expect(body.insole_type).toBe('Kurk'); + expect(body.pair_count).toBe(2); + expect(body.status).toBe('active'); + expect(body.source).toBe('app'); + expect(body.end_time).toBeNull(); + expect(body.duration_seconds).toBeNull(); + expect(typeof body.start_time).toBe('string'); + expect(typeof body.user_id).toBe('string'); + }); + + it('400s start with a bad body', async () => { + const app = createApp(); + const token = await authToken(app, 'sess-badbody@example.com'); + + const res = await app.request('/api/sessions/start', { + method: 'POST', + headers: bearer(token), + body: JSON.stringify({}), + }); + expect(res.status).toBe(400); + }); + + it('404s start for a missing activity', async () => { + const app = createApp(); + const token = await authToken(app, 'sess-missingact@example.com'); + + const res = await app.request('/api/sessions/start', { + method: 'POST', + headers: bearer(token), + body: JSON.stringify({ activity_id: 999999, insole_type: 'Kurk', pair_count: 2 }), + }); + expect(res.status).toBe(404); + }); + + it('completes a session and computes duration', async () => { + const app = createApp(); + const token = await authToken(app, 'sess-complete@example.com'); + const activityId = await createActivity(app, token, 'Slijpen'); + + const startRes = await app.request('/api/sessions/start', { + method: 'POST', + headers: bearer(token), + body: JSON.stringify({ activity_id: activityId, insole_type: 'Berk', pair_count: 2 }), + }); + const started = await startRes.json(); + + // Make the duration deterministic: backdate the start time by exactly 5s. + 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.status).toBe('completed'); + expect(body.end_time).not.toBeNull(); + expect(body.duration_seconds).toBe(5); + }); + + it('409s stopping an already-completed session', async () => { + const app = createApp(); + const token = await authToken(app, 'sess-doublestop@example.com'); + const activityId = await createActivity(app, token, '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(); + + const first = await app.request(`/api/sessions/${started.id}/stop`, { + method: 'POST', + headers: bearer(token), + }); + expect(first.status).toBe(200); + + const second = await app.request(`/api/sessions/${started.id}/stop`, { + method: 'POST', + headers: bearer(token), + }); + expect(second.status).toBe(409); + }); + + it('discards an active session', async () => { + const app = createApp(); + const token = await authToken(app, 'sess-discard@example.com'); + const activityId = await createActivity(app, token, '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(); + + const res = await app.request(`/api/sessions/${started.id}/discard`, { + method: 'POST', + headers: bearer(token), + }); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.status).toBe('discarded'); + expect(body.duration_seconds).toBeNull(); + }); + + it("does not let user B stop user A's session", async () => { + const app = createApp(); + const tokenA = await authToken(app, 'sess-ownerA@example.com'); + const tokenB = await authToken(app, 'sess-ownerB@example.com'); + const activityId = await createActivity(app, tokenA, 'Printen'); + + const startRes = await app.request('/api/sessions/start', { + method: 'POST', + headers: bearer(tokenA), + body: JSON.stringify({ activity_id: activityId, insole_type: '3D', pair_count: 2 }), + }); + const started = await startRes.json(); + + const res = await app.request(`/api/sessions/${started.id}/stop`, { + method: 'POST', + headers: bearer(tokenB), + }); + expect(res.status).toBe(404); + + // Verify via db the session is still active. + const [row] = await db.select().from(workSessions).where(eq(workSessions.id, started.id)); + expect(row.status).toBe('active'); + expect(row.endTime).toBeNull(); + }); +});