feat(api): server-authoritative session start/stop/discard with ownership scoping
This commit is contained in:
@@ -2,6 +2,7 @@ import { Hono } from 'hono';
|
|||||||
import { health } from './routes/health';
|
import { health } from './routes/health';
|
||||||
import { me } from './routes/me';
|
import { me } from './routes/me';
|
||||||
import { activitiesRoutes } from './routes/activities';
|
import { activitiesRoutes } from './routes/activities';
|
||||||
|
import { sessionsRoutes } from './routes/sessions';
|
||||||
import { auth } from './auth';
|
import { auth } from './auth';
|
||||||
|
|
||||||
export function createApp(): Hono {
|
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.on(['POST', 'GET'], '/api/auth/*', (c) => auth.handler(c.req.raw));
|
||||||
app.route('/', me);
|
app.route('/', me);
|
||||||
app.route('/', activitiesRoutes);
|
app.route('/', activitiesRoutes);
|
||||||
|
app.route('/', sessionsRoutes);
|
||||||
return app;
|
return app;
|
||||||
}
|
}
|
||||||
|
|||||||
106
apps/api/src/routes/sessions.ts
Normal file
106
apps/api/src/routes/sessions.ts
Normal file
@@ -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));
|
||||||
|
});
|
||||||
209
apps/api/test/sessions.test.ts
Normal file
209
apps/api/test/sessions.test.ts
Normal file
@@ -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<string> {
|
||||||
|
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<string, string> {
|
||||||
|
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<number> {
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user