From a82136fc95fe1835b0696a35586877211baca2f2 Mon Sep 17 00:00:00 2001 From: Tyler Gillispie Date: Tue, 25 Aug 2026 09:33:44 -0700 Subject: [PATCH] feat(cockpit): add live event metrics, seat roster, and master kill-switch --- server/main.test.ts | 134 ++++++++++++++ server/routes/events.ts | 93 ++++++++++ ...live-metrics-and-master-killswitch-0047.md | 0 ui/components/SessionsPage.tsx | 170 ++++++++++++++++++ ui/mod.ts | 8 + 5 files changed, 405 insertions(+) rename tasks/{new => complete}/2026-0825.03.gem.feat.event-cockpit.live-metrics-and-master-killswitch-0047.md (100%) 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 Cockpit Deck (Live Workshops) +

+
+ {eventPasses.map((event) => ( +
+
+
+

+ {event.name} +

+
+ Expires: {new Date(event.expires_at).toLocaleString()} +
+
+ Active +
+ + {/* Progress Bar for Seats */} +
+
+ Seats Claimed + + {event.seats_claimed} /{" "} + {event.max_seats === 0 ? "∞" : event.max_seats} + +
+
+
0 + ? Math.min( + (event.seats_claimed / event.max_seats) * 100, + 100, + ) + : 100 + }%;`} + > +
+
+
+ + {/* Quick Copy Snippets */} +
+
+ + PIN: + + + {event.pin_code} + + +
+
+ + Link: + + + /e/{event.slug} + + +
+
+ + {/* Cockpit Actions */} +
+ + +
+
+ ))} +
+
+ )} +

@@ -884,6 +995,65 @@ export const SessionsPage = ({ } } + function copyText(text) { + navigator.clipboard.writeText(text); + showNotice('Copied to clipboard!', false); + } + + // Event Cockpit Actions + document.querySelectorAll('.extend-event-btn').forEach(btn => { + btn.addEventListener('click', async (e) => { + const eventId = e.currentTarget.getAttribute('data-event-id'); + e.currentTarget.disabled = true; + try { + const res = await fetch(\`/api/events/\${eventId}/extend\`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ extendHours: 1 }) + }); + if (res.ok) { + showNotice('Event extended by 1 hour!', false); + setTimeout(() => window.location.reload(), 600); + } else { + const data = await res.json(); + showNotice(data.error || 'Failed to extend event', true); + e.currentTarget.disabled = false; + } + } catch (err) { + showNotice('Network error extending event', true); + e.currentTarget.disabled = false; + } + }); + }); + + document.querySelectorAll('.end-event-btn').forEach(btn => { + btn.addEventListener('click', async (e) => { + if (!confirm('Are you sure you want to end this workshop? This will immediately revoke ALL active guest sessions and they will lose access.')) return; + + const eventId = e.currentTarget.getAttribute('data-event-id'); + e.currentTarget.disabled = true; + e.currentTarget.textContent = 'Revoking...'; + + try { + const res = await fetch(\`/api/events/\${eventId}/end\`, { + method: 'POST', + }); + if (res.ok) { + showNotice('Workshop ended and all sessions revoked.', false); + setTimeout(() => window.location.reload(), 600); + } else { + const data = await res.json(); + showNotice(data.error || 'Failed to end event', true); + e.currentTarget.disabled = false; + e.currentTarget.textContent = '🔴 End & Revoke All'; + } catch (err) { + showNotice('Network error ending event', true); + e.currentTarget.disabled = false; + e.currentTarget.textContent = '🔴 End & Revoke All'; + } + }); + }); + // Revoke handler document.querySelectorAll('.revoke-btn').forEach(btn => { btn.addEventListener('click', async (e) => { diff --git a/ui/mod.ts b/ui/mod.ts index 2be9664..b98b99a 100644 --- a/ui/mod.ts +++ b/ui/mod.ts @@ -195,12 +195,20 @@ uiApp.get("/dashboard/sessions", async (c) => { ORDER BY created_at DESC `; + const eventPasses = await sql` + SELECT id, slug, pin_code, name, max_seats, seats_claimed, is_active, expires_at + FROM event_passes + WHERE created_by = ${auth.userId} AND is_active = TRUE + ORDER BY created_at DESC + `; + return c.html( SessionsPage({ sessions, currentSessionId: auth.sessionId, apps: apps as any, isAdmin, + eventPasses: eventPasses as any, }), ); });