feat(cockpit): add live event metrics, seat roster, and master kill-switch
This commit is contained in:
parent
62fc91eb25
commit
a82136fc95
@ -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();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@ -13,6 +13,99 @@ export const eventRoutes = new Hono();
|
|||||||
// Multi-Claim Event Passes & Short Code Join Portal
|
// 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) => {
|
eventRoutes.post("/api/events", async (c) => {
|
||||||
const user = await getAuthenticatedUser(c);
|
const user = await getAuthenticatedUser(c);
|
||||||
if (!user) return c.json({ error: "Unauthorized" }, 401);
|
if (!user) return c.json({ error: "Unauthorized" }, 401);
|
||||||
|
|||||||
@ -5,11 +5,13 @@ export const SessionsPage = ({
|
|||||||
currentSessionId,
|
currentSessionId,
|
||||||
apps = [],
|
apps = [],
|
||||||
isAdmin = false,
|
isAdmin = false,
|
||||||
|
eventPasses = [],
|
||||||
}: {
|
}: {
|
||||||
sessions: any[];
|
sessions: any[];
|
||||||
currentSessionId: string;
|
currentSessionId: string;
|
||||||
apps?: any[];
|
apps?: any[];
|
||||||
isAdmin?: boolean;
|
isAdmin?: boolean;
|
||||||
|
eventPasses?: any[];
|
||||||
}) => {
|
}) => {
|
||||||
return (
|
return (
|
||||||
<AuthenticatedLayout
|
<AuthenticatedLayout
|
||||||
@ -22,6 +24,115 @@ export const SessionsPage = ({
|
|||||||
style="display: none; margin-bottom: 1rem; padding: 0.75rem 1rem; border-radius: var(--radius-md); font-size: 0.9rem;"
|
style="display: none; margin-bottom: 1rem; padding: 0.75rem 1rem; border-radius: var(--radius-md); font-size: 0.9rem;"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{eventPasses && eventPasses.length > 0 && (
|
||||||
|
<div style="margin-bottom: 2rem;">
|
||||||
|
<h2 style="font-size: 1.25rem; font-weight: 700; margin: 0 0 1rem 0; color: var(--text-primary); border-bottom: 1px solid var(--border-subtle); padding-bottom: 0.5rem;">
|
||||||
|
Event Cockpit Deck (Live Workshops)
|
||||||
|
</h2>
|
||||||
|
<div style="display: grid; grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); gap: 1rem;">
|
||||||
|
{eventPasses.map((event) => (
|
||||||
|
<div
|
||||||
|
key={event.id}
|
||||||
|
class="card"
|
||||||
|
style="border-left: 4px solid var(--primary); margin: 0;"
|
||||||
|
>
|
||||||
|
<div style="display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 0.75rem;">
|
||||||
|
<div>
|
||||||
|
<h3 style="margin: 0 0 0.25rem 0; font-size: 1.1rem; color: var(--text-primary);">
|
||||||
|
{event.name}
|
||||||
|
</h3>
|
||||||
|
<div style="font-size: 0.8rem; color: var(--text-secondary);">
|
||||||
|
Expires: {new Date(event.expires_at).toLocaleString()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span class="badge badge-success">Active</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Progress Bar for Seats */}
|
||||||
|
<div style="margin-bottom: 1rem;">
|
||||||
|
<div style="display: flex; justify-content: space-between; font-size: 0.8rem; margin-bottom: 0.25rem; color: var(--text-secondary);">
|
||||||
|
<span>Seats Claimed</span>
|
||||||
|
<strong style="color: var(--text-primary);">
|
||||||
|
{event.seats_claimed} /{" "}
|
||||||
|
{event.max_seats === 0 ? "∞" : event.max_seats}
|
||||||
|
</strong>
|
||||||
|
</div>
|
||||||
|
<div style="width: 100%; height: 8px; background: var(--surface-muted); border-radius: 4px; overflow: hidden;">
|
||||||
|
<div
|
||||||
|
style={`height: 100%; background: var(--primary); width: ${
|
||||||
|
event.max_seats > 0
|
||||||
|
? Math.min(
|
||||||
|
(event.seats_claimed / event.max_seats) * 100,
|
||||||
|
100,
|
||||||
|
)
|
||||||
|
: 100
|
||||||
|
}%;`}
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Quick Copy Snippets */}
|
||||||
|
<div style="display: flex; flex-direction: column; gap: 0.5rem; margin-bottom: 1rem;">
|
||||||
|
<div style="display: flex; align-items: center; gap: 0.5rem;">
|
||||||
|
<span style="font-size: 0.8rem; font-weight: 600; width: 60px; color: var(--text-muted);">
|
||||||
|
PIN:
|
||||||
|
</span>
|
||||||
|
<code style="flex: 1; padding: 0.35rem 0.5rem; background: var(--surface-muted); border-radius: var(--radius-sm); font-family: monospace; font-size: 0.9rem; text-align: center; letter-spacing: 2px;">
|
||||||
|
{event.pin_code}
|
||||||
|
</code>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn-outline"
|
||||||
|
style="padding: 0.25rem 0.5rem; font-size: 0.75rem; min-height: 28px;"
|
||||||
|
onclick={`copyText('${event.pin_code}')`}
|
||||||
|
>
|
||||||
|
Copy
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div style="display: flex; align-items: center; gap: 0.5rem;">
|
||||||
|
<span style="font-size: 0.8rem; font-weight: 600; width: 60px; color: var(--text-muted);">
|
||||||
|
Link:
|
||||||
|
</span>
|
||||||
|
<code style="flex: 1; padding: 0.35rem 0.5rem; background: var(--surface-muted); border-radius: var(--radius-sm); font-family: monospace; font-size: 0.75rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;">
|
||||||
|
/e/{event.slug}
|
||||||
|
</code>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn-outline"
|
||||||
|
style="padding: 0.25rem 0.5rem; font-size: 0.75rem; min-height: 28px;"
|
||||||
|
onclick={`copyText(window.location.origin + '/e/${event.slug}')`}
|
||||||
|
>
|
||||||
|
Copy
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Cockpit Actions */}
|
||||||
|
<div style="display: flex; gap: 0.5rem;">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn-outline extend-event-btn"
|
||||||
|
data-event-id={event.id}
|
||||||
|
style="flex: 1; justify-content: center; min-height: 38px; font-size: 0.85rem;"
|
||||||
|
>
|
||||||
|
+1h Extend
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn-danger end-event-btn"
|
||||||
|
data-event-id={event.id}
|
||||||
|
style="flex: 1; justify-content: center; min-height: 38px; font-size: 0.85rem;"
|
||||||
|
>
|
||||||
|
🔴 End & Revoke All
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div style="display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 1.5rem; flex-wrap: wrap; gap: 1rem;">
|
<div style="display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 1.5rem; flex-wrap: wrap; gap: 1rem;">
|
||||||
<div>
|
<div>
|
||||||
<h1 style="font-size: 1.75rem; font-weight: 700; margin: 0 0 0.5rem 0; color: var(--text-primary);">
|
<h1 style="font-size: 1.75rem; font-weight: 700; margin: 0 0 0.5rem 0; color: var(--text-primary);">
|
||||||
@ -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
|
// Revoke handler
|
||||||
document.querySelectorAll('.revoke-btn').forEach(btn => {
|
document.querySelectorAll('.revoke-btn').forEach(btn => {
|
||||||
btn.addEventListener('click', async (e) => {
|
btn.addEventListener('click', async (e) => {
|
||||||
|
|||||||
@ -195,12 +195,20 @@ uiApp.get("/dashboard/sessions", async (c) => {
|
|||||||
ORDER BY created_at DESC
|
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(
|
return c.html(
|
||||||
SessionsPage({
|
SessionsPage({
|
||||||
sessions,
|
sessions,
|
||||||
currentSessionId: auth.sessionId,
|
currentSessionId: auth.sessionId,
|
||||||
apps: apps as any,
|
apps: apps as any,
|
||||||
isAdmin,
|
isAdmin,
|
||||||
|
eventPasses: eventPasses as any,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user