4 Commits
Author SHA1 Message Date
Michilis 183a2c99ed Merge pull request 'Restrict whole-event door takings to admin and organizer.' (#35) from dev into main
Reviewed-on: #35
2026-08-23 06:38:57 +00:00
MichilisandClaude Opus 5 745af4184f Restrict whole-event door takings to admin and organizer.
The door session sheet showed every staff member what the event had
taken overall, by tender and against pre-sale. That is management
information, not door information, and it matches the convention
already applied to the other revenue aggregates (admin/analytics,
admin/export/financial are both admin-only).

Door staff keep their own shift cash-up: the "This session" totals are
computed on the device from its own action log, so nothing they need to
reconcile at the end of the night is lost.

The gate is on GET /api/events/:eventId/door-summary, not only on the
section that renders it -- hiding the panel while the endpoint still
returned the figures would leave them one network response away. The
client skips the request entirely for staff rather than provoking a 403.

The test auth mock previously waved every role through, so it could not
have caught a wrong gate; it now honours the role list, which also puts
several already-written assertions onto real code paths.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 06:35:57 +00:00
Michilis 279cd19308 Merge pull request 'Fix the backend build against hono's narrowed route param type.' (#34) from dev into main
Reviewed-on: #34
2026-08-23 06:16:27 +00:00
MichilisandClaude Opus 5 02a12ee9e0 Fix the backend build against hono's narrowed route param type.
npm audit fix floated hono from 4.4 to 4.11 within the ^4.4.7 range and
tsc stopped compiling. c.req.param(key) has two overloads: it returns
string only when the route path's literal type survives inference, and
string | undefined otherwise. requireAuth() returns a handler annotated
with a bare Context, which erases the path generic, so every param read
behind it is now string | undefined.

Only four sites failed. Everywhere else the value lands in
eq((table as any).id, ...), where the any swallows it. Routes that also
run zValidator kept their typing, since that middleware is generic and
restores the inference -- door.ts:251 compiles while the two plain
requireAuth routes beside it do not.

loadEvent now accepts string | undefined and returns null for a missing
id, which both door callers already handle with their 404 branch. The
payments and tickets handlers pass the id off the row they just fetched
and null-checked rather than the raw param.

This leaves the underlying erasure in place. Typing requireAuth to
preserve the path generic would restore string on every authed route,
but it touches all of them and belongs in its own change.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 06:15:28 +00:00
6 changed files with 96 additions and 21 deletions
+61 -6
View File
@@ -14,17 +14,36 @@ process.env.BETTER_AUTH_SECRET = 'door-test-secret-0123456789abcdef';
delete process.env.REDIS_URL; delete process.env.REDIS_URL;
const STAFF = { id: 'staff-user-id', name: 'Door Staff', role: 'staff' }; const STAFF = { id: 'staff-user-id', name: 'Door Staff', role: 'staff' };
const ADMIN = { id: 'admin-user-id', name: 'The Admin', role: 'admin' };
const ORGANIZER = { id: 'organizer-user-id', name: 'The Organizer', role: 'organizer' };
// Who the next request is from. Session auth itself is Better Auth's concern and
// has its own integration suite; this mock keeps the *role* check real so the
// tests can prove which endpoints door staff may reach.
let currentUser: { id: string; name: string; role: string } = STAFF;
// The door endpoints are behind staff auth; the flows under test are the writes,
// not Better Auth, which has its own integration suite.
vi.mock('../lib/auth.js', () => ({ vi.mock('../lib/auth.js', () => ({
requireAuth: () => async (c: any, next: any) => { requireAuth: (roles?: string[]) => async (c: any, next: any) => {
c.set('user', STAFF); if (roles && !roles.includes(currentUser.role)) {
return c.json({ error: 'Forbidden' }, 403);
}
c.set('user', currentUser);
await next(); await next();
}, },
getAuthUser: async () => STAFF, getAuthUser: async () => currentUser,
})); }));
/** Run one request as a given role, always restoring the default afterwards. */
async function as<T>(user: typeof STAFF, fn: () => Promise<T>): Promise<T> {
const previous = currentUser;
currentUser = user;
try {
return await fn();
} finally {
currentUser = previous;
}
}
// Walk-ins with an email trigger a confirmation send; keep it out of the test. // Walk-ins with an email trigger a confirmation send; keep it out of the test.
vi.mock('../lib/email.js', () => ({ vi.mock('../lib/email.js', () => ({
default: { sendBookingConfirmation: vi.fn(async () => ({ success: true })) }, default: { sendBookingConfirmation: vi.fn(async () => ({ success: true })) },
@@ -362,9 +381,45 @@ describe('undo', () => {
}); });
}); });
describe('door-summary access', () => {
it('is hidden from door staff — whole-event takings are not door information', async () => {
const { status, body } = await get(`/api/events/${EVENT_ID}/door-summary`);
expect(status).toBe(403);
// The numbers must not leak in the body either: hiding the section in the UI
// alone would still expose them to anyone reading the network response.
expect(body).not.toHaveProperty('door');
expect(body).not.toHaveProperty('presale');
});
it('is available to admin and organizer', async () => {
for (const role of [ADMIN, ORGANIZER]) {
const { status } = await as(role, () => get(`/api/events/${EVENT_ID}/door-summary`));
expect(status, `${role.role} should see door takings`).toBe(200);
}
});
it('still lets door staff do their job — list, check in and undo', async () => {
expect((await get(`/api/events/${EVENT_ID}/door-attendees`)).status).toBe(200);
// Comp, so this ticket stays out of the revenue totals asserted below and
// the two tests cannot drift into each other through the shared database.
seedTicket({ id: 'tkt-role', first: 'Role', last: 'Check', status: 'confirmed', paymentStatus: 'comp' });
const checkin = await post(`/api/events/${EVENT_ID}/door-checkin`, {
ticketId: 'tkt-role',
idempotencyKey: 'key-role-check',
});
expect(checkin.status).toBe(201);
const undo = await post(`/api/events/${EVENT_ID}/door-checkin/undo`, {
idempotencyKey: 'key-role-check',
});
expect(undo.status).toBe(200);
});
});
describe('door-summary', () => { describe('door-summary', () => {
it('totals door takings by tender and splits them from pre-sale', async () => { it('totals door takings by tender and splits them from pre-sale', async () => {
const { status, body } = await get(`/api/events/${EVENT_ID}/door-summary`); const { status, body } = await as(ADMIN, () => get(`/api/events/${EVENT_ID}/door-summary`));
expect(status).toBe(200); expect(status).toBe(200);
// Cash: tkt-unpaid + the 'Walk' and 'Overflow' walk-ins (the undone ones are // Cash: tkt-unpaid + the 'Walk' and 'Overflow' walk-ins (the undone ones are
+10 -3
View File
@@ -8,7 +8,8 @@
// POST /:eventId/door-checkin the single write endpoint — checks in, settles // POST /:eventId/door-checkin the single write endpoint — checks in, settles
// payment, or creates a walk-in, atomically // payment, or creates a walk-in, atomically
// POST /:eventId/door-checkin/undo reverses exactly what one keyed action did // POST /:eventId/door-checkin/undo reverses exactly what one keyed action did
// GET /:eventId/door-summary end-of-night cash-up + pre-sale/door revenue split // GET /:eventId/door-summary end-of-night cash-up + pre-sale/door revenue
// split (admin/organizer only)
// //
// Every write carries a client-generated idempotencyKey. The key is inserted in // Every write carries a client-generated idempotencyKey. The key is inserted in
// the same transaction as the writes, so a double tap or a retry after a timeout // the same transaction as the writes, so a double tap or a retry after a timeout
@@ -35,6 +36,11 @@ import emailService from '../lib/email.js';
const doorRouter = new Hono(); const doorRouter = new Hono();
const STAFF_ROLES = ['admin', 'organizer', 'staff'] as const; const STAFF_ROLES = ['admin', 'organizer', 'staff'] as const;
// Whole-event money is management information, not door information: door staff
// reconcile their own shift from the session feed the client keeps locally, and
// never see what the event took overall. Matches the existing convention for
// revenue aggregates (admin/export/financial, admin/analytics).
const REVENUE_ROLES = ['admin', 'organizer'] as const;
const IDEMPOTENCY_SCOPE = 'door-checkin'; const IDEMPOTENCY_SCOPE = 'door-checkin';
// ==================== Shared helpers ==================== // ==================== Shared helpers ====================
@@ -84,7 +90,8 @@ function toDoorAttendee(
}; };
} }
async function loadEvent(eventId: string) { async function loadEvent(eventId: string | undefined) {
if (!eventId) return null;
const event = await dbGet<any>( const event = await dbGet<any>(
(db as any).select().from(events).where(eq((events as any).id, eventId)) (db as any).select().from(events).where(eq((events as any).id, eventId))
); );
@@ -572,7 +579,7 @@ doorRouter.post(
// End-of-night reconciliation: what was taken at the door, by tender, plus the // End-of-night reconciliation: what was taken at the door, by tender, plus the
// pre-sale/door split the event dashboard shows. // pre-sale/door split the event dashboard shows.
doorRouter.get('/:eventId/door-summary', requireAuth([...STAFF_ROLES]), async (c) => { doorRouter.get('/:eventId/door-summary', requireAuth([...REVENUE_ROLES]), async (c) => {
const eventId = c.req.param('eventId'); const eventId = c.req.param('eventId');
const event = await loadEvent(eventId); const event = await loadEvent(eventId);
+1 -1
View File
@@ -667,7 +667,7 @@ paymentsRouter.post('/:id/send-reminder', requireAuth(['admin', 'organizer']), a
} }
try { try {
const result = await emailService.sendPaymentReminder(id); const result = await emailService.sendPaymentReminder(payment.id);
if (result.success) { if (result.success) {
const now = getNow(); const now = getNow();
+1 -1
View File
@@ -1174,7 +1174,7 @@ ticketsRouter.post('/:id/mark-paid', requireAuth(['admin', 'organizer', 'staff']
// Send confirmation emails asynchronously (don't block the response) // Send confirmation emails asynchronously (don't block the response)
Promise.all([ Promise.all([
emailService.sendBookingConfirmation(id), emailService.sendBookingConfirmation(ticket.id),
payment ? emailService.sendPaymentReceipt(payment.id) : Promise.resolve(), payment ? emailService.sendPaymentReceipt(payment.id) : Promise.resolve(),
]).catch(err => { ]).catch(err => {
console.error('[Email] Failed to send confirmation emails:', err); console.error('[Email] Failed to send confirmation emails:', err);
@@ -96,6 +96,7 @@ export function SessionSheet({
entries, entries,
summary, summary,
summaryLoading, summaryLoading,
showEventTotals,
currency, currency,
onRefresh, onRefresh,
onClose, onClose,
@@ -103,6 +104,8 @@ export function SessionSheet({
entries: SessionEntry[]; entries: SessionEntry[];
summary: DoorSummary | null; summary: DoorSummary | null;
summaryLoading: boolean; summaryLoading: boolean;
/** Whole-event takings are admin/organizer only; door staff see their own shift. */
showEventTotals: boolean;
currency: string; currency: string;
onRefresh: () => void; onRefresh: () => void;
onClose: () => void; onClose: () => void;
@@ -118,13 +121,15 @@ export function SessionSheet({
<p className="text-xs text-gray-500">{liveEntries.length} checked in from this device</p> <p className="text-xs text-gray-500">{liveEntries.length} checked in from this device</p>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<button {showEventTotals && (
onClick={onRefresh} <button
className="min-w-[48px] min-h-[48px] flex items-center justify-center rounded-full text-gray-400 active:text-white active:scale-95 transition-all" onClick={onRefresh}
aria-label="Refresh totals" className="min-w-[48px] min-h-[48px] flex items-center justify-center rounded-full text-gray-400 active:text-white active:scale-95 transition-all"
> aria-label="Refresh totals"
<ArrowPathIcon className={clsx('w-5 h-5', summaryLoading && 'animate-spin')} /> >
</button> <ArrowPathIcon className={clsx('w-5 h-5', summaryLoading && 'animate-spin')} />
</button>
)}
<button <button
onClick={onClose} onClick={onClose}
className="min-w-[48px] min-h-[48px] flex items-center justify-center rounded-full text-gray-400 active:text-white active:scale-95 transition-all" className="min-w-[48px] min-h-[48px] flex items-center justify-center rounded-full text-gray-400 active:text-white active:scale-95 transition-all"
@@ -145,7 +150,9 @@ export function SessionSheet({
<CashUpGrid totals={totals} currency={currency} /> <CashUpGrid totals={totals} currency={currency} />
</section> </section>
{/* Whole event, from the server — the number to reconcile the cash box against */} {/* Whole event, from the server — the number to reconcile the cash box
against. Admin/organizer only; the API enforces the same split. */}
{showEventTotals && (
<section className="space-y-2"> <section className="space-y-2">
<div className="flex items-baseline justify-between"> <div className="flex items-baseline justify-between">
<h2 className="text-sm font-bold text-white uppercase tracking-wide">Door total, whole event</h2> <h2 className="text-sm font-bold text-white uppercase tracking-wide">Door total, whole event</h2>
@@ -175,6 +182,7 @@ export function SessionSheet({
</p> </p>
)} )}
</section> </section>
)}
{/* Feed */} {/* Feed */}
<section className="space-y-2"> <section className="space-y-2">
+7 -2
View File
@@ -66,6 +66,10 @@ export default function AdminDoorPage() {
const router = useRouter(); const router = useRouter();
const { user } = useAuth(); const { user } = useAuth();
const backHref = user?.role === 'staff' ? '/admin/events' : '/admin'; const backHref = user?.role === 'staff' ? '/admin/events' : '/admin';
// Whole-event takings are management information. Door staff reconcile their
// own shift from the session feed below, which is local to this device; the
// API enforces the same split (see REVENUE_ROLES in routes/door.ts).
const canSeeEventTotals = user?.role === 'admin' || user?.role === 'organizer';
// ── Events ── // ── Events ──
const [events, setEvents] = useState<Event[]>([]); const [events, setEvents] = useState<Event[]>([]);
@@ -531,7 +535,7 @@ export default function AdminDoorPage() {
// ─── Session summary ───────────────────────────────────────── // ─── Session summary ─────────────────────────────────────────
const loadSummary = useCallback(async () => { const loadSummary = useCallback(async () => {
const eventId = selectedEventIdRef.current; const eventId = selectedEventIdRef.current;
if (!eventId) return; if (!eventId || !canSeeEventTotals) return;
setSummaryLoading(true); setSummaryLoading(true);
try { try {
setSummary(await doorApi.summary(eventId)); setSummary(await doorApi.summary(eventId));
@@ -540,7 +544,7 @@ export default function AdminDoorPage() {
} finally { } finally {
setSummaryLoading(false); setSummaryLoading(false);
} }
}, []); }, [canSeeEventTotals]);
useEffect(() => { useEffect(() => {
if (sessionOpen) loadSummary(); if (sessionOpen) loadSummary();
@@ -733,6 +737,7 @@ export default function AdminDoorPage() {
entries={sessionEntries} entries={sessionEntries}
summary={summary} summary={summary}
summaryLoading={summaryLoading} summaryLoading={summaryLoading}
showEventTotals={canSeeEventTotals}
currency={currency} currency={currency}
onRefresh={loadSummary} onRefresh={loadSummary}
onClose={() => { onClose={() => {