diff --git a/server/main.test.ts b/server/main.test.ts index 3311d59..d5875b3 100644 --- a/server/main.test.ts +++ b/server/main.test.ts @@ -1195,4 +1195,138 @@ Deno.test("Multi-Claim Event Passes & Join Endpoints", async (t) => { } }, ); + + await t.step( + "POST /api/events/:id/end master kill switch revokes sessions", + async () => { + const valkeyGetStub = stub(valkey, "get", (key: any) => { + if (String(key) === "admin-session") { + return Promise.resolve( + JSON.stringify({ uuid: "admin-uuid", username: "tylerg" }), + ); + } + return Promise.resolve(null); + }); + + const originalSql = sqlWrapper.sql; + let updateEventCalled = false; + let deleteSessionsCalled = false; + + sqlWrapper.sql = ((strings: any, ..._values: any[]) => { + const query = Array.isArray(strings) + ? strings.join("?") + : String(strings); + if ( + query.includes("UPDATE event_passes") && + query.includes("is_active = FALSE") + ) { + updateEventCalled = true; + return Promise.resolve([{ slug: "deno-lab" }]); + } + if (query.includes("DELETE FROM sessions")) { + deleteSessionsCalled = true; + return Promise.resolve([{ id: "sess-1" }, { id: "sess-2" }]); + } + return Promise.resolve([]); + }) as any; + + const valkeyDelStub = stub( + valkey, + "del", + () => Promise.resolve(2 as any), + ); + + try { + const res = await app.request("/api/events/evt-123/end", { + method: "POST", + headers: { + Authorization: "Bearer admin-session", + }, + }); + + assertEquals(res.status, 200); + const json = await res.json(); + assert(json.success === true); + assertEquals(json.revokedCount, 2); + assert(updateEventCalled); + assert(deleteSessionsCalled); + } finally { + sqlWrapper.sql = originalSql; + valkeyGetStub.restore(); + valkeyDelStub.restore(); + } + }, + ); + + await t.step( + "POST /api/events/:id/extend adds time to event and sessions", + async () => { + const valkeyGetStub = stub(valkey, "get", (key: any) => { + if (String(key) === "admin-session") { + return Promise.resolve( + JSON.stringify({ uuid: "admin-uuid", username: "tylerg" }), + ); + } + return Promise.resolve(null); + }); + + const futureDate = new Date(Date.now() + 7200000).toISOString(); + const originalSql = sqlWrapper.sql; + let updateEventCalled = false; + let updateSessionsCalled = false; + + sqlWrapper.sql = ((strings: any, ..._values: any[]) => { + const query = Array.isArray(strings) + ? strings.join("?") + : String(strings); + if ( + query.includes("UPDATE event_passes") && + query.includes("expires_at = expires_at + interval") + ) { + updateEventCalled = true; + return Promise.resolve([{ + slug: "deno-lab", + expires_at: futureDate, + }]); + } + if ( + query.includes("UPDATE sessions") && + query.includes("expires_at = expires_at + interval") + ) { + updateSessionsCalled = true; + return Promise.resolve([{ id: "sess-1" }, { id: "sess-2" }]); + } + return Promise.resolve([]); + }) as any; + + let expireCallCount = 0; + const valkeyExpireStub = stub(valkey, "expire", () => { + expireCallCount++; + return Promise.resolve(1 as any); + }); + + try { + const res = await app.request("/api/events/evt-123/extend", { + method: "POST", + headers: { + Authorization: "Bearer admin-session", + "Content-Type": "application/json", + }, + body: JSON.stringify({ extendHours: 2 }), + }); + + assertEquals(res.status, 200); + const json = await res.json(); + assert(json.success === true); + assertEquals(json.expiresAt, futureDate); + assert(updateEventCalled); + assert(updateSessionsCalled); + assertEquals(expireCallCount, 2); + } finally { + sqlWrapper.sql = originalSql; + valkeyGetStub.restore(); + valkeyExpireStub.restore(); + } + }, + ); }); diff --git a/server/routes/events.ts b/server/routes/events.ts index 09be065..ea9944a 100644 --- a/server/routes/events.ts +++ b/server/routes/events.ts @@ -13,6 +13,99 @@ export const eventRoutes = new Hono(); // Multi-Claim Event Passes & Short Code Join Portal // --------------------------------------------------------- +eventRoutes.post("/api/events/:id/end", async (c) => { + const user = await getAuthenticatedUser(c); + if (!user) return c.json({ error: "Unauthorized" }, 401); + + const eventId = c.req.param("id"); + + try { + const eventResult = await sqlWrapper.sql` + UPDATE event_passes + SET is_active = FALSE + WHERE id = ${eventId} AND created_by = ${user.userId} + RETURNING slug + `; + + if (!eventResult || eventResult.length === 0) { + return c.json({ error: "Event not found or unauthorized" }, 404); + } + + const slug = eventResult[0].slug; + + const sessionResult = await sqlWrapper.sql` + DELETE FROM sessions + WHERE user_id IN ( + SELECT id FROM users WHERE username LIKE ${"guest_" + slug + "_%"} + ) + RETURNING id + `; + + const sessionIds = sessionResult.map((s: any) => s.id); + if (sessionIds.length > 0) { + await valkey.del(...sessionIds); + } + + return c.json({ success: true, revokedCount: sessionIds.length }); + } catch (e: any) { + console.error("[Events] Failed to end event:", e); + return c.json({ error: "Failed to end event" }, 500); + } +}); + +eventRoutes.post("/api/events/:id/extend", async (c) => { + const user = await getAuthenticatedUser(c); + if (!user) return c.json({ error: "Unauthorized" }, 401); + + const eventId = c.req.param("id"); + const body = await c.req.json().catch(() => ({})); + const extendHours = Math.max(Number(body.extendHours) || 1, 1); + + try { + const eventResult = await sqlWrapper.sql` + UPDATE event_passes + SET expires_at = expires_at + interval '${extendHours} hours' + WHERE id = ${eventId} AND created_by = ${user.userId} AND is_active = TRUE + RETURNING slug, expires_at + `; + + if (!eventResult || eventResult.length === 0) { + return c.json( + { error: "Event not found, inactive, or unauthorized" }, + 404, + ); + } + + const slug = eventResult[0].slug; + const newExpiresAt = new Date(eventResult[0].expires_at); + + const sessionResult = await sqlWrapper.sql` + UPDATE sessions + SET expires_at = expires_at + interval '${extendHours} hours' + WHERE user_id IN ( + SELECT id FROM users WHERE username LIKE ${"guest_" + slug + "_%"} + ) + RETURNING id + `; + + const sessionIds = sessionResult.map((s: any) => s.id); + if (sessionIds.length > 0) { + const ttlSeconds = Math.max( + 1, + Math.floor((newExpiresAt.getTime() - Date.now()) / 1000), + ); + for (const sid of sessionIds) { + await valkey.expire(sid, ttlSeconds); + } + } + + return c.json({ success: true, expiresAt: newExpiresAt.toISOString() }); + } catch (e: any) { + console.error("[Events] Failed to extend event:", e); + return c.json({ error: "Failed to extend event" }, 500); + } +}); + eventRoutes.post("/api/events", async (c) => { const user = await getAuthenticatedUser(c); if (!user) return c.json({ error: "Unauthorized" }, 401); diff --git a/tasks/new/2026-0825.03.gem.feat.event-cockpit.live-metrics-and-master-killswitch-0047.md b/tasks/complete/2026-0825.03.gem.feat.event-cockpit.live-metrics-and-master-killswitch-0047.md similarity index 100% rename from tasks/new/2026-0825.03.gem.feat.event-cockpit.live-metrics-and-master-killswitch-0047.md rename to tasks/complete/2026-0825.03.gem.feat.event-cockpit.live-metrics-and-master-killswitch-0047.md diff --git a/ui/components/SessionsPage.tsx b/ui/components/SessionsPage.tsx index 4428330..a850ae7 100644 --- a/ui/components/SessionsPage.tsx +++ b/ui/components/SessionsPage.tsx @@ -5,11 +5,13 @@ export const SessionsPage = ({ currentSessionId, apps = [], isAdmin = false, + eventPasses = [], }: { sessions: any[]; currentSessionId: string; apps?: any[]; isAdmin?: boolean; + eventPasses?: any[]; }) => { return ( + {eventPasses && eventPasses.length > 0 && ( +
+ {event.pin_code}
+
+
+