feat(api): role-aware session helper + admin-only activity writes
This commit is contained in:
@@ -1,8 +1,18 @@
|
|||||||
import type { Context } from 'hono';
|
import type { Context } from 'hono';
|
||||||
import { auth } from '../auth';
|
import { auth } from '../auth';
|
||||||
|
|
||||||
export async function getSessionUser(c: Context): Promise<{ id: string } | null> {
|
export interface SessionUser {
|
||||||
|
id: string;
|
||||||
|
role: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getSessionUser(c: Context): Promise<SessionUser | null> {
|
||||||
const session = await auth.api.getSession({ headers: c.req.raw.headers });
|
const session = await auth.api.getSession({ headers: c.req.raw.headers });
|
||||||
if (!session) return null;
|
if (!session) return null;
|
||||||
return { id: session.user.id };
|
const role = (session.user as { role?: string | null }).role ?? 'worker';
|
||||||
|
return { id: session.user.id, role };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isAdmin(u: SessionUser | null): boolean {
|
||||||
|
return u?.role === 'admin';
|
||||||
}
|
}
|
||||||
|
|||||||
27
apps/api/src/lib/work-session.ts
Normal file
27
apps/api/src/lib/work-session.ts
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import type { WorkSession } from '@solelog/shared';
|
||||||
|
import type { workSessions } from '../db/schema';
|
||||||
|
|
||||||
|
type WorkSessionRow = typeof workSessions.$inferSelect;
|
||||||
|
|
||||||
|
export function toWorkSession(
|
||||||
|
row: WorkSessionRow,
|
||||||
|
opts: { activityName?: string | null; userName?: string | null; userEmail?: string | null } = {}
|
||||||
|
): WorkSession {
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
user_id: row.userId,
|
||||||
|
activity_id: row.activityId,
|
||||||
|
activity_name: opts.activityName ?? undefined,
|
||||||
|
user_name: opts.userName ?? undefined,
|
||||||
|
user_email: opts.userEmail ?? 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(),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -4,7 +4,7 @@ import { CreateActivityInput, UpdateActivityInput } from '@solelog/shared';
|
|||||||
import type { Activity } from '@solelog/shared';
|
import type { Activity } from '@solelog/shared';
|
||||||
import { db } from '../db/client';
|
import { db } from '../db/client';
|
||||||
import { activities, workSessions } from '../db/schema';
|
import { activities, workSessions } from '../db/schema';
|
||||||
import { getSessionUser } from '../lib/require-user';
|
import { getSessionUser, isAdmin } from '../lib/require-user';
|
||||||
|
|
||||||
export const activitiesRoutes = new Hono();
|
export const activitiesRoutes = new Hono();
|
||||||
|
|
||||||
@@ -34,6 +34,7 @@ activitiesRoutes.get('/api/activities', async (c) => {
|
|||||||
activitiesRoutes.post('/api/activities', async (c) => {
|
activitiesRoutes.post('/api/activities', 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);
|
||||||
|
if (!isAdmin(sessionUser)) return c.json({ error: 'Forbidden' }, 403);
|
||||||
|
|
||||||
const parsed = CreateActivityInput.safeParse(await c.req.json().catch(() => null));
|
const parsed = CreateActivityInput.safeParse(await c.req.json().catch(() => null));
|
||||||
if (!parsed.success) return c.json({ error: 'Invalid input' }, 400);
|
if (!parsed.success) return c.json({ error: 'Invalid input' }, 400);
|
||||||
@@ -48,6 +49,7 @@ activitiesRoutes.post('/api/activities', async (c) => {
|
|||||||
activitiesRoutes.put('/api/activities/:id', async (c) => {
|
activitiesRoutes.put('/api/activities/:id', 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);
|
||||||
|
if (!isAdmin(sessionUser)) return c.json({ error: 'Forbidden' }, 403);
|
||||||
|
|
||||||
const id = Number.parseInt(c.req.param('id'), 10);
|
const id = Number.parseInt(c.req.param('id'), 10);
|
||||||
if (Number.isNaN(id)) return c.json({ error: 'Activity not found' }, 404);
|
if (Number.isNaN(id)) return c.json({ error: 'Activity not found' }, 404);
|
||||||
@@ -67,6 +69,7 @@ activitiesRoutes.put('/api/activities/:id', async (c) => {
|
|||||||
activitiesRoutes.delete('/api/activities/:id', async (c) => {
|
activitiesRoutes.delete('/api/activities/:id', 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);
|
||||||
|
if (!isAdmin(sessionUser)) return c.json({ error: 'Forbidden' }, 403);
|
||||||
|
|
||||||
const id = Number.parseInt(c.req.param('id'), 10);
|
const id = Number.parseInt(c.req.param('id'), 10);
|
||||||
if (Number.isNaN(id)) return c.json({ error: 'Activity not found' }, 404);
|
if (Number.isNaN(id)) return c.json({ error: 'Activity not found' }, 404);
|
||||||
|
|||||||
@@ -1,34 +1,14 @@
|
|||||||
import { Hono } from 'hono';
|
import { Hono } from 'hono';
|
||||||
import { and, asc, desc, eq } from 'drizzle-orm';
|
import { and, asc, desc, eq } from 'drizzle-orm';
|
||||||
import { StartSessionInput } from '@solelog/shared';
|
import { StartSessionInput } from '@solelog/shared';
|
||||||
import type { WorkSession } from '@solelog/shared';
|
|
||||||
import { db } from '../db/client';
|
import { db } from '../db/client';
|
||||||
import { activities, workSessions } from '../db/schema';
|
import { activities, workSessions } from '../db/schema';
|
||||||
import { getSessionUser } from '../lib/require-user';
|
import { getSessionUser } from '../lib/require-user';
|
||||||
|
import { toWorkSession } from '../lib/work-session';
|
||||||
import { quote, formatDuration } from '../lib/csv';
|
import { quote, formatDuration } from '../lib/csv';
|
||||||
|
|
||||||
export const sessionsRoutes = new Hono();
|
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.get('/api/export', async (c) => {
|
sessionsRoutes.get('/api/export', 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);
|
||||||
@@ -95,7 +75,7 @@ sessionsRoutes.get('/api/sessions/active', async (c) => {
|
|||||||
.where(and(eq(workSessions.userId, sessionUser.id), eq(workSessions.status, 'active')))
|
.where(and(eq(workSessions.userId, sessionUser.id), eq(workSessions.status, 'active')))
|
||||||
.orderBy(desc(workSessions.startTime));
|
.orderBy(desc(workSessions.startTime));
|
||||||
|
|
||||||
return c.json(rows.map((r) => toWorkSession(r.session, r.activityName)));
|
return c.json(rows.map((r) => toWorkSession(r.session, { activityName: r.activityName })));
|
||||||
});
|
});
|
||||||
|
|
||||||
sessionsRoutes.get('/api/sessions', async (c) => {
|
sessionsRoutes.get('/api/sessions', async (c) => {
|
||||||
@@ -109,7 +89,7 @@ sessionsRoutes.get('/api/sessions', async (c) => {
|
|||||||
.where(eq(workSessions.userId, sessionUser.id))
|
.where(eq(workSessions.userId, sessionUser.id))
|
||||||
.orderBy(desc(workSessions.startTime));
|
.orderBy(desc(workSessions.startTime));
|
||||||
|
|
||||||
return c.json(rows.map((r) => toWorkSession(r.session, r.activityName)));
|
return c.json(rows.map((r) => toWorkSession(r.session, { activityName: r.activityName })));
|
||||||
});
|
});
|
||||||
|
|
||||||
sessionsRoutes.post('/api/sessions/start', async (c) => {
|
sessionsRoutes.post('/api/sessions/start', async (c) => {
|
||||||
@@ -139,7 +119,7 @@ sessionsRoutes.post('/api/sessions/start', async (c) => {
|
|||||||
source: 'app',
|
source: 'app',
|
||||||
})
|
})
|
||||||
.returning();
|
.returning();
|
||||||
return c.json(toWorkSession(row, activity.name));
|
return c.json(toWorkSession(row, { activityName: activity.name }));
|
||||||
});
|
});
|
||||||
|
|
||||||
sessionsRoutes.post('/api/sessions/:id/stop', async (c) => {
|
sessionsRoutes.post('/api/sessions/:id/stop', async (c) => {
|
||||||
@@ -157,7 +137,9 @@ sessionsRoutes.post('/api/sessions/:id/stop', async (c) => {
|
|||||||
if (row.status !== 'active') return c.json({ error: 'Session already closed' }, 409);
|
if (row.status !== 'active') return c.json({ error: 'Session already closed' }, 409);
|
||||||
|
|
||||||
const endTime = new Date();
|
const endTime = new Date();
|
||||||
const durationSeconds = Math.round((endTime.getTime() - new Date(row.startTime).getTime()) / 1000);
|
const durationSeconds = Math.round(
|
||||||
|
(endTime.getTime() - new Date(row.startTime).getTime()) / 1000
|
||||||
|
);
|
||||||
|
|
||||||
const [updated] = await db
|
const [updated] = await db
|
||||||
.update(workSessions)
|
.update(workSessions)
|
||||||
|
|||||||
@@ -24,9 +24,27 @@ describe('activities routes', () => {
|
|||||||
expect(res.status).toBe(401);
|
expect(res.status).toBe(401);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('forbids a worker from creating an activity (403)', async () => {
|
||||||
|
const app = createApp();
|
||||||
|
const token = await authToken(app, 'act-worker-create@example.com'); // default worker
|
||||||
|
const res = await app.request('/api/activities', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: bearer(token),
|
||||||
|
body: JSON.stringify({ name: 'Frezen', insole_types: ['Kurk'] }),
|
||||||
|
});
|
||||||
|
expect(res.status).toBe(403);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lets a worker read activities (200)', async () => {
|
||||||
|
const app = createApp();
|
||||||
|
const token = await authToken(app, 'act-worker-read@example.com');
|
||||||
|
const res = await app.request('/api/activities', { headers: bearer(token) });
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
});
|
||||||
|
|
||||||
it('creates an activity and lists it', async () => {
|
it('creates an activity and lists it', async () => {
|
||||||
const app = createApp();
|
const app = createApp();
|
||||||
const token = await authToken(app, 'act-create@example.com');
|
const token = await authToken(app, 'act-create@example.com', 'admin');
|
||||||
|
|
||||||
const createRes = await app.request('/api/activities', {
|
const createRes = await app.request('/api/activities', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -49,7 +67,7 @@ describe('activities routes', () => {
|
|||||||
|
|
||||||
it('defaults insole_types to all three when omitted', async () => {
|
it('defaults insole_types to all three when omitted', async () => {
|
||||||
const app = createApp();
|
const app = createApp();
|
||||||
const token = await authToken(app, 'act-default@example.com');
|
const token = await authToken(app, 'act-default@example.com', 'admin');
|
||||||
|
|
||||||
const res = await app.request('/api/activities', {
|
const res = await app.request('/api/activities', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -63,7 +81,7 @@ describe('activities routes', () => {
|
|||||||
|
|
||||||
it('filters by ?insole_type', async () => {
|
it('filters by ?insole_type', async () => {
|
||||||
const app = createApp();
|
const app = createApp();
|
||||||
const token = await authToken(app, 'act-filter@example.com');
|
const token = await authToken(app, 'act-filter@example.com', 'admin');
|
||||||
|
|
||||||
const printRes = await app.request('/api/activities', {
|
const printRes = await app.request('/api/activities', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -87,7 +105,7 @@ describe('activities routes', () => {
|
|||||||
|
|
||||||
it('400s POST with an empty name', async () => {
|
it('400s POST with an empty name', async () => {
|
||||||
const app = createApp();
|
const app = createApp();
|
||||||
const token = await authToken(app, 'act-emptyname@example.com');
|
const token = await authToken(app, 'act-emptyname@example.com', 'admin');
|
||||||
|
|
||||||
const res = await app.request('/api/activities', {
|
const res = await app.request('/api/activities', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -99,7 +117,7 @@ describe('activities routes', () => {
|
|||||||
|
|
||||||
it('updates an activity', async () => {
|
it('updates an activity', async () => {
|
||||||
const app = createApp();
|
const app = createApp();
|
||||||
const token = await authToken(app, 'act-update@example.com');
|
const token = await authToken(app, 'act-update@example.com', 'admin');
|
||||||
|
|
||||||
const createRes = await app.request('/api/activities', {
|
const createRes = await app.request('/api/activities', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -122,7 +140,7 @@ describe('activities routes', () => {
|
|||||||
|
|
||||||
it('404s PUT for a missing id', async () => {
|
it('404s PUT for a missing id', async () => {
|
||||||
const app = createApp();
|
const app = createApp();
|
||||||
const token = await authToken(app, 'act-put404@example.com');
|
const token = await authToken(app, 'act-put404@example.com', 'admin');
|
||||||
|
|
||||||
const res = await app.request('/api/activities/999999', {
|
const res = await app.request('/api/activities/999999', {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
@@ -134,7 +152,7 @@ describe('activities routes', () => {
|
|||||||
|
|
||||||
it('deletes an activity and its sessions', async () => {
|
it('deletes an activity and its sessions', async () => {
|
||||||
const app = createApp();
|
const app = createApp();
|
||||||
const token = await authToken(app, 'act-delete@example.com');
|
const token = await authToken(app, 'act-delete@example.com', 'admin');
|
||||||
|
|
||||||
const createRes = await app.request('/api/activities', {
|
const createRes = await app.request('/api/activities', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
|
|||||||
@@ -5,17 +5,7 @@ import { db } from '../src/db/client';
|
|||||||
import { workSessions } from '../src/db/schema';
|
import { workSessions } from '../src/db/schema';
|
||||||
import { eq } from 'drizzle-orm';
|
import { eq } from 'drizzle-orm';
|
||||||
import { quote, formatDuration } from '../src/lib/csv';
|
import { quote, formatDuration } from '../src/lib/csv';
|
||||||
import { authToken, bearer } from './helpers';
|
import { authToken, bearer, seedActivity } from './helpers';
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Start a session, backdate its start_time by `durationSeconds`, then stop it,
|
// Start a session, backdate its start_time by `durationSeconds`, then stop it,
|
||||||
// producing a completed session with an exact duration.
|
// producing a completed session with an exact duration.
|
||||||
@@ -50,7 +40,7 @@ describe('csv export', () => {
|
|||||||
it('exports completed sessions as CSV with the legacy header', async () => {
|
it('exports completed sessions as CSV with the legacy header', async () => {
|
||||||
const app = createApp();
|
const app = createApp();
|
||||||
const token = await authToken(app, 'export-basic@example.com');
|
const token = await authToken(app, 'export-basic@example.com');
|
||||||
const activityId = await createActivity(app, token, 'Frezen');
|
const activityId = await seedActivity('Frezen');
|
||||||
await completedSession(app, token, activityId, 'Kurk', 90);
|
await completedSession(app, token, activityId, 'Kurk', 90);
|
||||||
|
|
||||||
const res = await app.request('/api/export', { headers: bearer(token) });
|
const res = await app.request('/api/export', { headers: bearer(token) });
|
||||||
@@ -75,7 +65,7 @@ describe('csv export', () => {
|
|||||||
const app = createApp();
|
const app = createApp();
|
||||||
const tokenA = await authToken(app, 'export-scopeA@example.com');
|
const tokenA = await authToken(app, 'export-scopeA@example.com');
|
||||||
const tokenB = await authToken(app, 'export-scopeB@example.com');
|
const tokenB = await authToken(app, 'export-scopeB@example.com');
|
||||||
const activityId = await createActivity(app, tokenA, 'Slijpen');
|
const activityId = await seedActivity('Slijpen');
|
||||||
|
|
||||||
// User A: one completed session.
|
// User A: one completed session.
|
||||||
await completedSession(app, tokenA, activityId, 'Kurk', 30);
|
await completedSession(app, tokenA, activityId, 'Kurk', 30);
|
||||||
|
|||||||
Reference in New Issue
Block a user