feat(api): admin-only cross-user work-session views (/api/admin/sessions)
This commit is contained in:
@@ -4,6 +4,7 @@ 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 { sessionsRoutes } from './routes/sessions';
|
||||||
|
import { adminRoutes } from './routes/admin';
|
||||||
import { auth } from './auth';
|
import { auth } from './auth';
|
||||||
import { env } from './env';
|
import { env } from './env';
|
||||||
|
|
||||||
@@ -24,5 +25,6 @@ export function createApp(): Hono {
|
|||||||
app.route('/', me);
|
app.route('/', me);
|
||||||
app.route('/', activitiesRoutes);
|
app.route('/', activitiesRoutes);
|
||||||
app.route('/', sessionsRoutes);
|
app.route('/', sessionsRoutes);
|
||||||
|
app.route('/', adminRoutes);
|
||||||
return app;
|
return app;
|
||||||
}
|
}
|
||||||
|
|||||||
60
apps/api/src/routes/admin.ts
Normal file
60
apps/api/src/routes/admin.ts
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
import { Hono } from 'hono';
|
||||||
|
import { desc, eq } from 'drizzle-orm';
|
||||||
|
import { db } from '../db/client';
|
||||||
|
import { activities, user, workSessions } from '../db/schema';
|
||||||
|
import { getSessionUser, isAdmin } from '../lib/require-user';
|
||||||
|
import { toWorkSession } from '../lib/work-session';
|
||||||
|
|
||||||
|
export const adminRoutes = new Hono();
|
||||||
|
|
||||||
|
// Gate the whole /api/admin/* surface to admins.
|
||||||
|
adminRoutes.use('/api/admin/*', async (c, next) => {
|
||||||
|
const sessionUser = await getSessionUser(c);
|
||||||
|
if (!sessionUser) return c.json({ error: 'Unauthorized' }, 401);
|
||||||
|
if (!isAdmin(sessionUser)) return c.json({ error: 'Forbidden' }, 403);
|
||||||
|
await next();
|
||||||
|
});
|
||||||
|
|
||||||
|
const baseSelect = {
|
||||||
|
session: workSessions,
|
||||||
|
activityName: activities.name,
|
||||||
|
userName: user.name,
|
||||||
|
userEmail: user.email,
|
||||||
|
};
|
||||||
|
|
||||||
|
adminRoutes.get('/api/admin/sessions', async (c) => {
|
||||||
|
const rows = await db
|
||||||
|
.select(baseSelect)
|
||||||
|
.from(workSessions)
|
||||||
|
.leftJoin(activities, eq(workSessions.activityId, activities.id))
|
||||||
|
.leftJoin(user, eq(workSessions.userId, user.id))
|
||||||
|
.orderBy(desc(workSessions.startTime));
|
||||||
|
return c.json(
|
||||||
|
rows.map((r) =>
|
||||||
|
toWorkSession(r.session, {
|
||||||
|
activityName: r.activityName,
|
||||||
|
userName: r.userName,
|
||||||
|
userEmail: r.userEmail,
|
||||||
|
})
|
||||||
|
)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
adminRoutes.get('/api/admin/sessions/active', async (c) => {
|
||||||
|
const rows = await db
|
||||||
|
.select(baseSelect)
|
||||||
|
.from(workSessions)
|
||||||
|
.leftJoin(activities, eq(workSessions.activityId, activities.id))
|
||||||
|
.leftJoin(user, eq(workSessions.userId, user.id))
|
||||||
|
.where(eq(workSessions.status, 'active'))
|
||||||
|
.orderBy(desc(workSessions.startTime));
|
||||||
|
return c.json(
|
||||||
|
rows.map((r) =>
|
||||||
|
toWorkSession(r.session, {
|
||||||
|
activityName: r.activityName,
|
||||||
|
userName: r.userName,
|
||||||
|
userEmail: r.userEmail,
|
||||||
|
})
|
||||||
|
)
|
||||||
|
);
|
||||||
|
});
|
||||||
46
apps/api/test/admin.test.ts
Normal file
46
apps/api/test/admin.test.ts
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { createApp } from '../src/app';
|
||||||
|
import { authToken, bearer, seedActivity } from './helpers';
|
||||||
|
|
||||||
|
describe('admin session views', () => {
|
||||||
|
it('401s without a token', async () => {
|
||||||
|
const app = createApp();
|
||||||
|
expect((await app.request('/api/admin/sessions')).status).toBe(401);
|
||||||
|
expect((await app.request('/api/admin/sessions/active')).status).toBe(401);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('403s for a worker', async () => {
|
||||||
|
const app = createApp();
|
||||||
|
const token = await authToken(app, 'admin-view-worker@example.com'); // worker
|
||||||
|
expect((await app.request('/api/admin/sessions', { headers: bearer(token) })).status).toBe(403);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns ALL users' sessions for an admin, with user info", async () => {
|
||||||
|
const app = createApp();
|
||||||
|
const adminTok = await authToken(app, 'admin-view-admin@example.com', 'admin');
|
||||||
|
const workerTok = await authToken(app, 'admin-view-w2@example.com'); // worker
|
||||||
|
const activityId = await seedActivity('Frezen');
|
||||||
|
|
||||||
|
// Worker starts a session.
|
||||||
|
const started = await (
|
||||||
|
await app.request('/api/sessions/start', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: bearer(workerTok),
|
||||||
|
body: JSON.stringify({ activity_id: activityId, insole_type: 'Kurk', pair_count: 2 }),
|
||||||
|
})
|
||||||
|
).json();
|
||||||
|
|
||||||
|
const res = await app.request('/api/admin/sessions', { headers: bearer(adminTok) });
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
const body = await res.json();
|
||||||
|
const found = body.find((s: { id: number }) => s.id === started.id);
|
||||||
|
expect(found).toBeTruthy();
|
||||||
|
expect(found.user_email).toBe('admin-view-w2@example.com');
|
||||||
|
expect(found.activity_name).toBe('Frezen');
|
||||||
|
|
||||||
|
const active = await app.request('/api/admin/sessions/active', { headers: bearer(adminTok) });
|
||||||
|
expect(active.status).toBe(200);
|
||||||
|
const activeBody = await active.json();
|
||||||
|
expect(activeBody.some((s: { id: number }) => s.id === started.id)).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user