feat(api): session history and active-session recovery endpoints
This commit is contained in:
@@ -1,5 +1,5 @@
|
|||||||
import { Hono } from 'hono';
|
import { Hono } from 'hono';
|
||||||
import { and, eq } from 'drizzle-orm';
|
import { and, desc, eq } from 'drizzle-orm';
|
||||||
import { StartSessionInput } from '@solelog/shared';
|
import { StartSessionInput } from '@solelog/shared';
|
||||||
import type { WorkSession } from '@solelog/shared';
|
import type { WorkSession } from '@solelog/shared';
|
||||||
import { db } from '../db/client';
|
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) => {
|
sessionsRoutes.post('/api/sessions/start', 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);
|
||||||
|
|||||||
@@ -207,3 +207,108 @@ describe('session lifecycle', () => {
|
|||||||
expect(row.endTime).toBeNull();
|
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');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user