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 { 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;
|
||||
}
|
||||
|
||||
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));
|
||||
});
|
||||
Reference in New Issue
Block a user