From 35e6be6a78eacf9de7f90a43763286648ead1faa Mon Sep 17 00:00:00 2001 From: sacha <23283108+sacha-l@users.noreply.github.com> Date: Mon, 15 Jun 2026 16:54:04 +0200 Subject: [PATCH 1/3] feat(luma): verify submissions against checked-in Luma guests via API Replace the CSV guest upload (hackathon programs) with a lazy, on-demand sync of an event's CHECKED-IN guests from the Luma public API into program_signups. The submission gate verifies the email against that synced set, lazily re-syncing on a cache-miss (25s floor) and distinguishing a transient Luma failure (503, retry) from a definitive not-checked-in (403). Single-flight + sanity guard (a 0-result/truncated sweep never overwrites a good cache). Manual single-email add remains as an event-day fallback. CSV path is untouched for non-hackathon programs. Server: luma.client.js, luma-sync.service.js, gate wiring, sync + manual endpoints, luma_event_id + sync-state columns. Client: LumaGuestsSection. Validated live against the Bitrefill event; 15 new tests. --- .../components/admin/LumaGuestsSection.tsx | 268 ++++++++++++++++++ client/src/lib/api.ts | 71 +++++ client/src/pages/AdminProgramPage.tsx | 35 ++- server/.env.example | 7 + .../__tests__/submission.controller.test.js | 63 ++++ server/api/controllers/program.controller.js | 73 +++++ .../api/controllers/submission.controller.js | 49 +++- .../repositories/program-signup.repository.js | 91 ++++++ server/api/repositories/program.repository.js | 20 ++ server/api/routes/program.routes.js | 5 + .../__tests__/luma-sync.service.test.js | 116 ++++++++ server/api/services/luma-sync.service.js | 82 ++++++ server/api/services/luma.client.js | 119 ++++++++ server/api/utils/validation.js | 5 + .../20260615000000_program_luma_event.sql | 15 + 15 files changed, 1001 insertions(+), 18 deletions(-) create mode 100644 client/src/components/admin/LumaGuestsSection.tsx create mode 100644 server/api/services/__tests__/luma-sync.service.test.js create mode 100644 server/api/services/luma-sync.service.js create mode 100644 server/api/services/luma.client.js create mode 100644 supabase/migrations/20260615000000_program_luma_event.sql diff --git a/client/src/components/admin/LumaGuestsSection.tsx b/client/src/components/admin/LumaGuestsSection.tsx new file mode 100644 index 0000000..9e2bad6 --- /dev/null +++ b/client/src/components/admin/LumaGuestsSection.tsx @@ -0,0 +1,268 @@ +import { useCallback, useEffect, useMemo, useState } from "react"; +import { Loader2, RefreshCw, Trash2, Plus, Check, AlertTriangle } from "lucide-react"; +import { useToast } from "@/hooks/use-toast"; +import { api, type ApiProgram, type ApiProgramSignup, type AdminAuthArg, ApiError } from "@/lib/api"; + +interface Props { + program: ApiProgram; + signAuthHeader: () => Promise; + /** Reflect saved event id / sync state back into the parent program state. */ + onProgramChange?: (patch: Partial) => void; +} + +const relativeTime = (iso?: string | null) => { + if (!iso) return "NEVER"; + const ms = Date.now() - new Date(iso).getTime(); + if (!Number.isFinite(ms) || ms < 0) return "JUST NOW"; + const mins = Math.floor(ms / 60000); + if (mins < 1) return "JUST NOW"; + if (mins < 60) return `${mins} MIN AGO`; + const hrs = Math.floor(mins / 60); + if (hrs < 24) return `${hrs} HR AGO`; + return `${Math.floor(hrs / 24)} DAY AGO`; +}; + +const errMessage = (e: unknown) => + e instanceof ApiError ? e.message : (e as Error)?.message || "Unknown error"; + +// Admin guest panel for Luma-gated programs. Replaces the CSV uploader: the +// checked-in list is pulled from the Luma API on demand, with a manual single +// email add as the event-day fallback. +export function LumaGuestsSection({ program, signAuthHeader, onProgramChange }: Props) { + const { toast } = useToast(); + const slug = program.slug; + + const [eventId, setEventId] = useState(program.lumaEventId ?? ""); + const [savingEventId, setSavingEventId] = useState(false); + const [syncing, setSyncing] = useState(false); + + const [lumaCount, setLumaCount] = useState(null); + const [manual, setManual] = useState([]); + const [loading, setLoading] = useState(true); + + const [newEmail, setNewEmail] = useState(""); + const [newName, setNewName] = useState(""); + const [adding, setAdding] = useState(false); + const [busyId, setBusyId] = useState(null); + + const load = useCallback(() => { + let active = true; + setLoading(true); + (async () => { + try { + const auth = await signAuthHeader(); + const r = await api.listProgramSignups(slug, auth); + if (!active) return; + setLumaCount(r.data.filter((s) => s.source === "luma_api").length); + setManual(r.data.filter((s) => s.source === "manual")); + } catch (e) { + if (active) toast({ title: "Couldn't load guests", description: errMessage(e), variant: "destructive" }); + } finally { + if (active) setLoading(false); + } + })(); + return () => { + active = false; + }; + }, [slug, signAuthHeader, toast]); + + useEffect(() => load(), [load]); + + const hasEventId = Boolean(program.lumaEventId); + const eventIdDirty = eventId.trim() !== (program.lumaEventId ?? ""); + + const status = program.lastGuestSyncStatus; + const statusBad = typeof status === "string" && (status.startsWith("error") || status === "truncated" || status === "empty_guard"); + + const saveEventId = async () => { + setSavingEventId(true); + try { + const auth = await signAuthHeader(); + const r = await api.updateProgram(slug, { lumaEventId: eventId.trim() || null }, auth); + onProgramChange?.({ lumaEventId: r.data.lumaEventId ?? null }); + toast({ title: "Luma event id saved" }); + } catch (e) { + toast({ title: "Couldn't save event id", description: errMessage(e), variant: "destructive" }); + } finally { + setSavingEventId(false); + } + }; + + const syncNow = async () => { + setSyncing(true); + try { + const auth = await signAuthHeader(); + const r = await api.syncProgramGuests(slug, auth); + setLumaCount(r.data.checkedInCount); + onProgramChange?.({ lastGuestSyncAt: r.data.syncedAt, lastGuestSyncStatus: r.data.syncStatus }); + if (r.data.syncStatus === "ok") { + toast({ title: `Synced ${r.data.checkedInCount} checked-in guests` }); + } else { + toast({ + title: "Sync returned a warning", + description: `Status: ${r.data.syncStatus}. Last good list kept.`, + variant: "destructive", + }); + } + } catch (e) { + toast({ title: "Sync failed", description: errMessage(e), variant: "destructive" }); + } finally { + setSyncing(false); + } + }; + + const addManual = async () => { + const email = newEmail.trim(); + if (!email) return; + setAdding(true); + try { + const auth = await signAuthHeader(); + const r = await api.addProgramSignup(slug, { email, name: newName.trim() || null }, auth); + setManual((prev) => [r.data, ...prev.filter((m) => m.id !== r.data.id)]); + setNewEmail(""); + setNewName(""); + toast({ title: `Added ${r.data.email}` }); + } catch (e) { + toast({ title: "Couldn't add email", description: errMessage(e), variant: "destructive" }); + } finally { + setAdding(false); + } + }; + + const removeManual = async (id: string) => { + setBusyId(id); + try { + const auth = await signAuthHeader(); + await api.deleteProgramSignup(slug, id, auth); + setManual((prev) => prev.filter((m) => m.id !== id)); + toast({ title: "Removed" }); + } catch (e) { + toast({ title: "Couldn't remove", description: errMessage(e), variant: "destructive" }); + } finally { + setBusyId(null); + } + }; + + const countLabel = useMemo(() => (lumaCount === null ? "…" : String(lumaCount)), [lumaCount]); + + return ( +
+
+
+ ·LUMA-APPROVED GUESTS + + {countLabel} CHECKED-IN + + + ·SYNCED {relativeTime(program.lastGuestSyncAt)} + {statusBad ? ` (${status})` : ""} + +
+ LUMA API +
+ + {/* Luma event id */} +
+ +
+ setEventId(e.target.value)} + placeholder="evt-XXXXXXXXXXXX" + className="flex-1 min-w-[220px] bg-panel-deep border border-hairline px-2 py-1.5 font-mono text-[12px] text-display placeholder:text-label-dim" + /> + + +
+

+ THE CHECKED-IN GUEST LIST IS PULLED FROM LUMA. SUBMISSIONS ALSO TRIGGER A SYNC AUTOMATICALLY. + {!hasEventId ? " SET AN EVENT ID TO ACTIVATE THE GATE." : ""} +

+
+ + {/* Manual fallback add */} +
+
+ + ·MANUAL ADD (FALLBACK) +
+
+ setNewEmail(e.target.value)} + type="email" + placeholder="email@checked-in.com" + className="flex-1 min-w-[200px] bg-panel-deep border border-hairline px-2 py-1.5 font-mono text-[12px] text-display placeholder:text-label-dim" + /> + setNewName(e.target.value)} + placeholder="name (optional)" + className="w-[160px] bg-panel-deep border border-hairline px-2 py-1.5 font-mono text-[12px] text-display placeholder:text-label-dim" + /> + +
+

+ USE FOR ATTENDEES LUMA CAN'T COVER (CHECKED IN ON ANOTHER DEVICE, REGISTERED WITH A DIFFERENT EMAIL). +

+
+ + {/* Manual entries list (Luma-synced rows are not listed to minimize stored PII on screen) */} + {loading ? ( +
+ LOADING… +
+ ) : manual.length === 0 ? ( +

·NO MANUAL ENTRIES.

+ ) : ( +
+
·MANUAL ENTRIES ({manual.length})
+ {manual.map((m) => ( +
+
+
{m.email}
+ {m.name ?
{m.name}
: null} +
+ +
+ ))} +
+ )} +
+ ); +} diff --git a/client/src/lib/api.ts b/client/src/lib/api.ts index e4185fd..1b76be3 100644 --- a/client/src/lib/api.ts +++ b/client/src/lib/api.ts @@ -420,10 +420,26 @@ export type ApiProgram = { prizeTiers?: ApiPrizeTier[] | null; /** Set when a platform admin publishes results to the public program page. */ resultsPublishedAt?: string | null; + /** Luma event id (evt-…). When set + server has LUMA_API_KEY, the submit gate + * verifies emails against the event's checked-in guests synced from Luma. */ + lumaEventId?: string | null; + /** Last time the checked-in guest list was synced from Luma (admin display). */ + lastGuestSyncAt?: string | null; + /** Outcome of the last sync: 'ok' | 'truncated' | 'empty_guard' | 'error:…'. */ + lastGuestSyncStatus?: string | null; createdAt?: string; updatedAt?: string; }; +/** Result of a manual "Sync now" against the Luma API. */ +export type ProgramGuestSyncResult = { + syncStatus: string; + checkedInCount: number; + syncedAt: string; + upserted: number; + removed: number; +}; + /** At-a-glance counts for the program admin/judge header (no PII). */ export type ApiProgramStats = { confirmedParticipants: number; submissionsCount: number }; @@ -1488,6 +1504,61 @@ export const api = { return { status: "success" }; }, + // --- Luma guest sync (replaces CSV for Luma-gated programs) --- + + /** Pull the event's checked-in guests from Luma into program_signups now. */ + syncProgramGuests: async ( + slug: string, + authHeader?: AdminAuthArg, + ): Promise<{ status: string; data: ProgramGuestSyncResult }> => { + if (USE_MOCK_DATA) { + return { + status: "success", + data: { + syncStatus: "ok", + checkedInCount: 0, + syncedAt: new Date().toISOString(), + upserted: 0, + removed: 0, + }, + }; + } + return request(`/programs/${encodeURIComponent(slug)}/signups/sync`, { + method: "POST", + headers: adminAuthHeaders(authHeader), + }); + }, + + /** Manually add a single checked-in email (event-day fallback). Idempotent. */ + addProgramSignup: async ( + slug: string, + payload: { email: string; name?: string | null }, + authHeader?: AdminAuthArg, + ): Promise<{ status: string; data: ApiProgramSignup }> => { + if (USE_MOCK_DATA) { + const { mockProgramSignups } = await import("./mockPrograms"); + const row: ApiProgramSignup = { + id: `manual-${Date.now()}`, + programId: slug, + email: payload.email.trim().toLowerCase(), + name: payload.name ?? null, + wallet: null, + registeredAt: null, + source: "manual", + rawRow: null, + importedInBatchAt: new Date().toISOString(), + createdAt: new Date().toISOString(), + }; + mockProgramSignups[slug] = [row, ...(mockProgramSignups[slug] || [])]; + return { status: "success", data: row }; + } + return request(`/programs/${encodeURIComponent(slug)}/signups`, { + method: "POST", + headers: { ...adminAuthHeaders(authHeader), "Content-Type": "application/json" }, + body: JSON.stringify(payload), + }); + }, + /** * Phase 2 revamp: admin create project (issue #80). */ diff --git a/client/src/pages/AdminProgramPage.tsx b/client/src/pages/AdminProgramPage.tsx index 7bf2edd..e40613d 100644 --- a/client/src/pages/AdminProgramPage.tsx +++ b/client/src/pages/AdminProgramPage.tsx @@ -19,6 +19,7 @@ import { ProgramStatsHeader } from "@/components/admin/ProgramStatsHeader"; import { ProgramFormModal } from "@/components/admin/ProgramFormModal"; import { ProgramSponsorsSection } from "@/components/admin/ProgramSponsorsSection"; import { ProgramSignupsSection } from "@/components/admin/ProgramSignupsSection"; +import { LumaGuestsSection } from "@/components/admin/LumaGuestsSection"; import { ProgramAuditLogSection } from "@/components/admin/ProgramAuditLogSection"; import { useToast } from "@/hooks/use-toast"; import { cn } from "@/lib/utils"; @@ -371,11 +372,18 @@ const AdminProgramPage = () => { /> )} - + {program.programType === "hackathon" ? ( + setProgram((p) => (p ? { ...p, ...patch } : p))} + /> + ) : ( + + )} {program.programType !== "hackathon" && ( { {program.programType !== "hackathon" && ( )} - + {program.programType === "hackathon" ? ( + setProgram((p) => (p ? { ...p, ...patch } : p))} + /> + ) : ( + + )} )} diff --git a/server/.env.example b/server/.env.example index 791255d..d2d3a76 100644 --- a/server/.env.example +++ b/server/.env.example @@ -58,6 +58,13 @@ PRIZE_CONTACT_EMAIL= # SUBMIT_TEST_EMAILS=sacha@joinwebzero.com SUBMIT_TEST_EMAILS= +# Optional: Luma public API key (https://docs.luma.com). When set AND a program +# has a luma_event_id, the submit gate verifies the email against the event's +# CHECKED-IN guests, mirrored from Luma into program_signups (no CSV upload). +# Requires a Luma Plus subscription on the calendar. Leave empty to keep the +# CSV / SUBMIT_TEST_EMAILS gate behavior. +LUMA_API_KEY= + # Public base URL of the client, used to build links in outbound email (e.g. the # program-admin onboarding link). Defaults to the prod URL if unset. FRONTEND_URL=https://stadium.joinwebzero.com diff --git a/server/api/controllers/__tests__/submission.controller.test.js b/server/api/controllers/__tests__/submission.controller.test.js index 9d0e11a..aa9ab7b 100644 --- a/server/api/controllers/__tests__/submission.controller.test.js +++ b/server/api/controllers/__tests__/submission.controller.test.js @@ -34,6 +34,9 @@ vi.mock('../../services/program-audit-log.service.js', () => ({ default: { logSa vi.mock('../../services/prize-award.service.js', () => ({ default: { notifyWinners: vi.fn() }, })); +vi.mock('../../services/luma-sync.service.js', () => ({ + default: { isActive: vi.fn(() => false), syncProgram: vi.fn() }, +})); const programService = (await import('../../services/program.service.js')).default; const scoringService = (await import('../../services/scoring.service.js')).default; @@ -44,6 +47,7 @@ const scoreRepo = (await import('../../repositories/submission-score.repository. const signupRepo = (await import('../../repositories/program-signup.repository.js')).default; const confirmationService = (await import('../../services/submission-confirmation.service.js')).default; const prizeAwardService = (await import('../../services/prize-award.service.js')).default; +const lumaSyncService = (await import('../../services/luma-sync.service.js')).default; const submissionController = (await import('../submission.controller.js')).default; const mockRes = () => { @@ -173,6 +177,65 @@ describe('SubmissionController.submit (public)', () => { }); }); +describe('SubmissionController.submit — Luma-gated JIT verification', () => { + // A program whose gate is backed by the Luma API (event id set + key present). + const LUMA_PROGRAM = { ...PROGRAM, lumaEventId: 'evt-1', lastGuestSyncAt: null, lastGuestSyncStatus: null }; + + it('cache-miss → JIT sync finds the just-checked-in email → 201', async () => { + programService.findBySlug.mockResolvedValue(LUMA_PROGRAM); + lumaSyncService.isActive.mockReturnValue(true); + // absent before the sync, present after it + signupRepo.existsByEmail.mockResolvedValueOnce(false).mockResolvedValueOnce(true); + lumaSyncService.syncProgram.mockResolvedValue({ status: 'ok' }); + submissionRepo.findByEmail.mockResolvedValue(null); + submissionRepo.create.mockResolvedValue({ submission: { id: 'x', lumaEmail: 'ada@example.com' }, duplicate: false }); + confirmationService.send.mockResolvedValue({ ok: true }); + + const res = mockRes(); + await submissionController.submit({ params: { slug: 'bitrefill' }, body: GOOD_BODY }, res); + expect(lumaSyncService.syncProgram).toHaveBeenCalled(); + expect(res.status).toHaveBeenCalledWith(201); + }); + + it('cache-miss + Luma unreachable (sync error) → 503 transient, NOT a 403', async () => { + programService.findBySlug.mockResolvedValue(LUMA_PROGRAM); + lumaSyncService.isActive.mockReturnValue(true); + signupRepo.existsByEmail.mockResolvedValue(false); // absent before and after + lumaSyncService.syncProgram.mockResolvedValue({ status: 'error:Luma 429' }); + + const res = mockRes(); + await submissionController.submit({ params: { slug: 'bitrefill' }, body: GOOD_BODY }, res); + expect(res.status).toHaveBeenCalledWith(503); + expect(submissionRepo.create).not.toHaveBeenCalled(); + }); + + it('cache-miss + healthy sync still absent → 403 definitive', async () => { + programService.findBySlug.mockResolvedValue(LUMA_PROGRAM); + lumaSyncService.isActive.mockReturnValue(true); + signupRepo.existsByEmail.mockResolvedValue(false); + lumaSyncService.syncProgram.mockResolvedValue({ status: 'ok' }); + + const res = mockRes(); + await submissionController.submit({ params: { slug: 'bitrefill' }, body: GOOD_BODY }, res); + expect(res.status).toHaveBeenCalledWith(403); + }); + + it('recently synced (within floor) + last sync errored → 503 without re-syncing', async () => { + programService.findBySlug.mockResolvedValue({ + ...LUMA_PROGRAM, + lastGuestSyncAt: new Date().toISOString(), // fresh → inside the JIT floor + lastGuestSyncStatus: 'error:Luma 500', + }); + lumaSyncService.isActive.mockReturnValue(true); + signupRepo.existsByEmail.mockResolvedValue(false); + + const res = mockRes(); + await submissionController.submit({ params: { slug: 'bitrefill' }, body: GOOD_BODY }, res); + expect(lumaSyncService.syncProgram).not.toHaveBeenCalled(); + expect(res.status).toHaveBeenCalledWith(503); + }); +}); + describe('SubmissionController.upsertScore (judge)', () => { it('409s when the judge ballot is already submitted (locked)', async () => { ballotRepo.isSubmitted.mockResolvedValue(true); diff --git a/server/api/controllers/program.controller.js b/server/api/controllers/program.controller.js index 4414076..a76d0eb 100644 --- a/server/api/controllers/program.controller.js +++ b/server/api/controllers/program.controller.js @@ -2,6 +2,7 @@ import programService from '../services/program.service.js'; import programApplicationService from '../services/program-application.service.js'; import programSponsorService from '../services/program-sponsor.service.js'; import programSignupService from '../services/program-signup.service.js'; +import lumaSyncService from '../services/luma-sync.service.js'; import programInboxService, { inboxToCsv } from '../services/program-inbox.service.js'; import auditLog from '../services/program-audit-log.service.js'; import projectService from '../services/project.service.js'; @@ -755,6 +756,78 @@ class ProgramController { } } + // Admin: pull the checked-in guest list from Luma now. Mirrors into + // program_signups (source='luma_api'). Returns the sync status + counts so the + // admin UI can show "synced just now · M checked-in". + async syncGuests(req, res) { + try { + const { slug } = req.params; + const program = await programService.findBySlug(slug); + if (!program) { + return res.status(404).json({ status: 'error', message: 'Program not found' }); + } + if (!program.lumaEventId) { + return res.status(422).json({ + status: 'error', + message: 'No Luma event id set for this program. Save one first.', + }); + } + if (!lumaSyncService.isActive(program)) { + return res.status(503).json({ + status: 'error', + message: 'Luma is not configured on the server (LUMA_API_KEY missing).', + }); + } + const result = await lumaSyncService.syncProgram(program); + const checkedInCount = await programSignupRepository.countBySource(program.id, 'luma_api'); + res.status(200).json({ + status: 'success', + data: { ...result, syncStatus: result.status, checkedInCount, syncedAt: new Date().toISOString() }, + }); + auditLog.logSafe({ + programId: program.id, + actor: { chain: req.user?.chain, wallet: req.user?.address, email: req.user?.email }, + action: 'signups.luma_sync', + targetType: 'signups_batch', + targetId: null, + metadata: { syncStatus: result.status, upserted: result.upserted, removed: result.removed }, + }); + } catch (error) { + console.error('❌ Error syncing Luma guests:', error); + res.status(500).json({ status: 'error', message: 'Failed to sync Luma guests' }); + } + } + + // Admin: manually add a single checked-in email (event-day fallback for the + // cases Luma can't cover). Idempotent. + async addSignup(req, res) { + try { + const { slug } = req.params; + const program = await programService.findBySlug(slug); + if (!program) { + return res.status(404).json({ status: 'error', message: 'Program not found' }); + } + const email = typeof req.body?.email === 'string' ? req.body.email.trim() : ''; + const name = typeof req.body?.name === 'string' && req.body.name.trim() ? req.body.name.trim() : null; + if (!EMAIL_RE.test(email)) { + return res.status(400).json({ status: 'error', message: 'A valid email is required' }); + } + const signup = await programSignupRepository.addManual(program.id, email, name); + res.status(201).json({ status: 'success', data: signup }); + auditLog.logSafe({ + programId: program.id, + actor: { chain: req.user?.chain, wallet: req.user?.address, email: req.user?.email }, + action: 'signup.manual_add', + targetType: 'signup', + targetId: signup.id, + metadata: { email: signup.email }, + }); + } catch (error) { + console.error('❌ Error adding signup:', error); + res.status(500).json({ status: 'error', message: 'Failed to add signup' }); + } + } + async deleteSignup(req, res) { try { const { slug, signupId } = req.params; diff --git a/server/api/controllers/submission.controller.js b/server/api/controllers/submission.controller.js index c5cc127..b1646f6 100644 --- a/server/api/controllers/submission.controller.js +++ b/server/api/controllers/submission.controller.js @@ -8,8 +8,14 @@ import programJudgeBallotRepository from '../repositories/program-judge-ballot.r import { validateSubmission, validateScore, validatePrize, prizeTiersFor } from '../utils/submission.validator.js'; import submissionConfirmationService from '../services/submission-confirmation.service.js'; import prizeAwardService from '../services/prize-award.service.js'; +import lumaSyncService from '../services/luma-sync.service.js'; import auditLog from '../services/program-audit-log.service.js'; +// On a Luma-gated cache-miss we lazily re-sync, but only if the last sync is +// older than this floor. Bounds Luma calls when not-on-list emails retry, while +// keeping the worst-case "checked in seconds ago" wait small. +const JIT_SYNC_FLOOR_MS = 25 * 1000; + // Winner selection + publishing are platform-admin only. After requireProgramAdmin, // a global admin carries isGlobalAdmin === true; per-program admins do not. const isPlatformAdmin = (req) => req.user?.isGlobalAdmin === true; @@ -99,14 +105,41 @@ class SubmissionController { .filter(Boolean), ); const submitterEmail = String(v.value.lumaEmail || '').trim().toLowerCase(); - if ( - !testEmails.has(submitterEmail) && - !(await programSignupRepository.existsByEmail(program.id, v.value.lumaEmail)) - ) { - return res.status(403).json({ - status: 'error', - message: 'Only checked-in attendees can submit. Use the email you checked in with on Luma.', - }); + if (!testEmails.has(submitterEmail)) { + let present = await programSignupRepository.existsByEmail(program.id, submitterEmail); + // Luma-gated programs: on a miss, lazily re-sync the checked-in list + // (someone may have checked in seconds ago) before deciding. Respect a + // floor so repeated not-on-list retries can't hammer Luma. A sync + // FAILURE must not be reported to a legit attendee as "not on the list" + // — distinguish transient (retry) from definitive (rejected). + let transient = false; + if (!present && lumaSyncService.isActive(program)) { + const lastSync = program.lastGuestSyncAt ? Date.parse(program.lastGuestSyncAt) : 0; + const stale = !lastSync || Date.now() - lastSync > JIT_SYNC_FLOOR_MS; + if (stale) { + const sync = await lumaSyncService.syncProgram(program); + transient = sync.status === 'truncated' || String(sync.status).startsWith('error'); + present = await programSignupRepository.existsByEmail(program.id, submitterEmail); + } else { + // Synced very recently and still absent → treat the last sync's + // health as the verdict's confidence. + transient = + program.lastGuestSyncStatus === 'truncated' || + String(program.lastGuestSyncStatus || '').startsWith('error'); + } + } + if (!present) { + if (transient) { + return res.status(503).json({ + status: 'error', + message: "Couldn't verify your check-in right now. Please try again in a moment.", + }); + } + return res.status(403).json({ + status: 'error', + message: 'Only checked-in attendees can submit. Use the email you checked in with on Luma.', + }); + } } // Deadline is informational: a submit after event end is flagged late, not blocked. const late = !!(program.eventEndsAt && Date.now() > Date.parse(program.eventEndsAt)); diff --git a/server/api/repositories/program-signup.repository.js b/server/api/repositories/program-signup.repository.js index 0a3a4d6..c565372 100644 --- a/server/api/repositories/program-signup.repository.js +++ b/server/api/repositories/program-signup.repository.js @@ -75,6 +75,87 @@ class ProgramSignupRepository { return (data || []).map(transformSignup); } + // Reconcile the synced Luma check-in set into program_signups. Upserts every + // currently-checked-in guest as source='luma_api', then deletes any prior + // 'luma_api' row whose email is no longer in the set. Rows from other sources + // ('luma' CSV import, 'manual' admin fallback) are never touched, so the + // manual escape hatch survives every sync. Returns { upserted, removed }. + // + // `guests` is [{ email, name }] (already lowercased + checked-in by the + // caller). The caller is responsible for the sanity guard (never call this + // with an empty set when the cache is non-empty). + async replaceLumaGuests(programId, guests) { + const batchAt = new Date().toISOString(); + const seen = new Set(); + const payload = []; + for (const g of guests) { + const email = typeof g.email === 'string' ? g.email.trim().toLowerCase() : ''; + if (!email || seen.has(email)) continue; + seen.add(email); + payload.push({ + program_id: programId, + email, + name: g.name ?? null, + source: 'luma_api', + imported_in_batch_at: batchAt, + }); + } + + if (payload.length) { + const { error } = await supabase + .from('program_signups') + .upsert(payload, { onConflict: 'program_id,email' }); + if (error) throw error; + } + + // Remove luma_api rows that fell out of the checked-in set (e.g. a check-in + // was undone, or the event id changed). Done in JS membership since + // Supabase can't express "delete where email NOT IN (large list)" cleanly. + const { data: existing, error: selErr } = await supabase + .from('program_signups') + .select('id, email') + .eq('program_id', programId) + .eq('source', 'luma_api'); + if (selErr) throw selErr; + const stale = (existing || []).filter( + (r) => !seen.has(String(r.email).trim().toLowerCase()), + ); + let removed = 0; + if (stale.length) { + const { error: delErr } = await supabase + .from('program_signups') + .delete() + .in('id', stale.map((r) => r.id)); + if (delErr) throw delErr; + removed = stale.length; + } + return { upserted: payload.length, removed }; + } + + // Manual single-email add (admin fallback for the event-day edge cases Luma + // can't cover). Idempotent: re-adding an existing email is a no-op success. + // Returns the row. + async addManual(programId, email, name = null) { + const normalized = typeof email === 'string' ? email.trim().toLowerCase() : ''; + if (!normalized) throw new Error('email required'); + const { data, error } = await supabase + .from('program_signups') + .upsert( + { + program_id: programId, + email: normalized, + name: name ?? null, + source: 'manual', + imported_in_batch_at: new Date().toISOString(), + }, + { onConflict: 'program_id,email' }, + ) + .select('*') + .single(); + if (error) throw error; + return transformSignup(data); + } + async lastImportedAt(programId) { const { data, error } = await supabase .from('program_signups') @@ -106,6 +187,16 @@ class ProgramSignupRepository { return false; } + async countBySource(programId, source) { + const { count, error } = await supabase + .from('program_signups') + .select('*', { count: 'exact', head: true }) + .eq('program_id', programId) + .eq('source', source); + if (error) throw error; + return count ?? 0; + } + async countByProgramId(programId) { const { count, error } = await supabase .from('program_signups') diff --git a/server/api/repositories/program.repository.js b/server/api/repositories/program.repository.js index 65146f5..74aa3b4 100644 --- a/server/api/repositories/program.repository.js +++ b/server/api/repositories/program.repository.js @@ -22,6 +22,9 @@ const transformProgram = (row) => { content: row.content ?? null, prizeTiers: row.prize_tiers ?? null, resultsPublishedAt: row.results_published_at ?? null, + lumaEventId: row.luma_event_id ?? null, + lastGuestSyncAt: row.last_guest_sync_at ?? null, + lastGuestSyncStatus: row.last_guest_sync_status ?? null, createdAt: row.created_at, updatedAt: row.updated_at, }; @@ -46,6 +49,11 @@ const toSnakeCase = (data) => { if ('coverImageUrl' in data) row.cover_image_url = data.coverImageUrl ?? null; if ('content' in data) row.content = data.content ?? null; if ('prizeTiers' in data) row.prize_tiers = data.prizeTiers ?? null; + // Normalize an empty/whitespace luma_event_id to null (gate stays off). + if ('lumaEventId' in data) { + const v = typeof data.lumaEventId === 'string' ? data.lumaEventId.trim() : data.lumaEventId; + row.luma_event_id = v || null; + } return row; }; @@ -90,6 +98,18 @@ class ProgramRepository { return transformProgram(data); } + // Record the outcome of a Luma guest sync. `status` is a short machine string + // ('ok', 'empty_guard', 'truncated', 'error:') the admin UI surfaces. + // We always stamp last_guest_sync_at so the gate's TTL advances even on a + // guarded/failed sweep (prevents a hot retry loop on every miss). + async setGuestSyncState(programId, { syncedAt, status }) { + const { error } = await supabase + .from('programs') + .update({ last_guest_sync_at: syncedAt, last_guest_sync_status: status }) + .eq('id', programId); + if (error) throw error; + } + // Publish/unpublish the public results: set or clear results_published_at. async setResultsPublished(slug, publishedAt) { const { data, error } = await supabase diff --git a/server/api/routes/program.routes.js b/server/api/routes/program.routes.js index 76f98e3..0011508 100644 --- a/server/api/routes/program.routes.js +++ b/server/api/routes/program.routes.js @@ -116,6 +116,11 @@ router.delete( // shared SIWS middleware: parser runs first to produce req.body, then // requireProgramAdmin reads x-siws-auth from headers. router.get('/:slug/signups', requireProgramViewer('slug'), programController.listSignups); +// Luma-gated programs: pull checked-in guests from the Luma API instead of a CSV. +router.post('/:slug/signups/sync', requireProgramAdmin('slug'), programController.syncGuests); +// Manual single-email add (event-day fallback). Sits before the CSV import and +// :signupId routes; the static `import`/`sync` segments are matched first. +router.post('/:slug/signups', requireProgramAdmin('slug'), programController.addSignup); router.post( '/:slug/signups/import', ...csvBody, diff --git a/server/api/services/__tests__/luma-sync.service.test.js b/server/api/services/__tests__/luma-sync.service.test.js new file mode 100644 index 0000000..c2989e0 --- /dev/null +++ b/server/api/services/__tests__/luma-sync.service.test.js @@ -0,0 +1,116 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +vi.mock('../luma.client.js', () => ({ + fetchCheckedIn: vi.fn(), + isConfigured: vi.fn(() => true), +})); +vi.mock('../../repositories/program-signup.repository.js', () => ({ + default: { replaceLumaGuests: vi.fn(), countBySource: vi.fn() }, +})); +vi.mock('../../repositories/program.repository.js', () => ({ + default: { setGuestSyncState: vi.fn() }, +})); + +const luma = await import('../luma.client.js'); +const signupRepo = (await import('../../repositories/program-signup.repository.js')).default; +const programRepo = (await import('../../repositories/program.repository.js')).default; +const service = (await import('../luma-sync.service.js')).default; + +const PROGRAM = { id: 'prog-1', slug: 'bitrefill-2026', lumaEventId: 'evt-1' }; +const G = (email) => ({ email, name: null }); + +beforeEach(() => { + vi.clearAllMocks(); + luma.isConfigured.mockReturnValue(true); + signupRepo.replaceLumaGuests.mockResolvedValue({ upserted: 0, removed: 0 }); + signupRepo.countBySource.mockResolvedValue(0); + programRepo.setGuestSyncState.mockResolvedValue(); +}); + +describe('lumaSyncService.isActive', () => { + it('false without an event id', () => { + expect(service.isActive({ id: 'p', lumaEventId: null })).toBe(false); + }); + it('false when the server has no LUMA_API_KEY', () => { + luma.isConfigured.mockReturnValue(false); + expect(service.isActive(PROGRAM)).toBe(false); + }); + it('true with both', () => { + expect(service.isActive(PROGRAM)).toBe(true); + }); +}); + +describe('lumaSyncService.syncProgram', () => { + it('not_configured short-circuits with no fetch', async () => { + luma.isConfigured.mockReturnValue(false); + const r = await service.syncProgram(PROGRAM); + expect(r.status).toBe('not_configured'); + expect(luma.fetchCheckedIn).not.toHaveBeenCalled(); + }); + + it('ok: mirrors the checked-in set and stamps state', async () => { + luma.fetchCheckedIn.mockResolvedValue({ + total: 3, + checkedIn: [G('a@x.io'), G('b@x.io')], + truncated: false, + }); + signupRepo.replaceLumaGuests.mockResolvedValue({ upserted: 2, removed: 0 }); + + const r = await service.syncProgram(PROGRAM); + expect(r).toMatchObject({ status: 'ok', checkedIn: 2, upserted: 2 }); + expect(signupRepo.replaceLumaGuests).toHaveBeenCalledWith('prog-1', [G('a@x.io'), G('b@x.io')]); + expect(programRepo.setGuestSyncState).toHaveBeenCalledWith( + 'prog-1', + expect.objectContaining({ status: 'ok' }), + ); + }); + + it('empty_guard: a 0-result sweep does NOT wipe a non-empty cache', async () => { + luma.fetchCheckedIn.mockResolvedValue({ total: 0, checkedIn: [], truncated: false }); + signupRepo.countBySource.mockResolvedValue(120); // cache currently has 120 + + const r = await service.syncProgram(PROGRAM); + expect(r.status).toBe('empty_guard'); + expect(signupRepo.replaceLumaGuests).not.toHaveBeenCalled(); + expect(programRepo.setGuestSyncState).toHaveBeenCalledWith( + 'prog-1', + expect.objectContaining({ status: 'empty_guard' }), + ); + }); + + it('0 check-ins is allowed to write when the cache is also empty (pre-event)', async () => { + luma.fetchCheckedIn.mockResolvedValue({ total: 50, checkedIn: [], truncated: false }); + signupRepo.countBySource.mockResolvedValue(0); + + const r = await service.syncProgram(PROGRAM); + expect(r.status).toBe('ok'); + expect(signupRepo.replaceLumaGuests).toHaveBeenCalledWith('prog-1', []); + }); + + it('truncated: never overwrites on a partial sweep', async () => { + luma.fetchCheckedIn.mockResolvedValue({ total: 9999, checkedIn: [G('a@x.io')], truncated: true }); + const r = await service.syncProgram(PROGRAM); + expect(r.status).toBe('truncated'); + expect(signupRepo.replaceLumaGuests).not.toHaveBeenCalled(); + }); + + it('error: surfaces error: and still stamps the timestamp', async () => { + luma.fetchCheckedIn.mockRejectedValue(new Error('Luma 429')); + const r = await service.syncProgram(PROGRAM); + expect(r.status).toMatch(/^error:/); + expect(programRepo.setGuestSyncState).toHaveBeenCalledWith( + 'prog-1', + expect.objectContaining({ status: expect.stringMatching(/^error:/) }), + ); + }); + + it('single-flight: concurrent syncs share one Luma sweep', async () => { + let resolve; + luma.fetchCheckedIn.mockReturnValue(new Promise((r) => { resolve = r; })); + const p1 = service.syncProgram(PROGRAM); + const p2 = service.syncProgram(PROGRAM); + resolve({ total: 1, checkedIn: [G('a@x.io')], truncated: false }); + await Promise.all([p1, p2]); + expect(luma.fetchCheckedIn).toHaveBeenCalledTimes(1); + }); +}); diff --git a/server/api/services/luma-sync.service.js b/server/api/services/luma-sync.service.js new file mode 100644 index 0000000..ef8d0db --- /dev/null +++ b/server/api/services/luma-sync.service.js @@ -0,0 +1,82 @@ +// Mirrors an event's checked-in Luma guests into program_signups so the +// submission gate can verify "only checked-in attendees may submit" without us +// hosting a guest CSV. Pull model: lazy (on a submission cache-miss) + manual +// ("Sync now"). No background timer — freshness only matters at submit time, +// which is exactly when the gate triggers a sync. See luma.client.js. + +import { fetchCheckedIn, isConfigured } from './luma.client.js'; +import signupRepository from '../repositories/program-signup.repository.js'; +import programRepository from '../repositories/program.repository.js'; + +// One in-flight sync per program. Concurrent submitters that all miss the cache +// share a single Luma sweep instead of stampeding the API. +const inFlight = new Map(); + +function shortError(err) { + const msg = (err && err.message ? String(err.message) : 'unknown').slice(0, 60); + return `error:${msg}`; +} + +async function runSync(program) { + const programId = program.id; + const eventId = program.lumaEventId; + let status; + let result = { total: 0, checkedIn: 0, upserted: 0, removed: 0 }; + try { + const { total, checkedIn, truncated } = await fetchCheckedIn(eventId); + + if (truncated) { + // Partial sweep — never trust it enough to overwrite a good cache. + status = 'truncated'; + } else if (checkedIn.length === 0 && (await signupRepository.countBySource(programId, 'luma_api')) > 0) { + // Sanity guard: a 0-result sweep against a non-empty cache is almost + // certainly a wrong event id / revoked key / Luma blip. Keep last-good. + status = 'empty_guard'; + } else { + const { upserted, removed } = await signupRepository.replaceLumaGuests(programId, checkedIn); + status = 'ok'; + result = { total, checkedIn: checkedIn.length, upserted, removed }; + } + } catch (err) { + status = shortError(err); + } + + // Always advance the sync timestamp — even on guard/error — so the gate's TTL + // moves forward and a stream of cache-misses can't hammer Luma. The live + // return value (not the stamp) is what the gate reads to detect transients. + try { + await programRepository.setGuestSyncState(programId, { + syncedAt: new Date().toISOString(), + status, + }); + } catch { + // Stamping is best-effort; a failure here must not mask the sync result. + } + return { status, ...result }; +} + +class LumaSyncService { + // Whether the Luma gate is active for this program: it has an event id AND the + // server has a LUMA_API_KEY. Until both are true the gate stays in its prior + // (CSV / test-email) mode so an unconfigured deploy can't lock everyone out. + isActive(program) { + return Boolean(program?.lumaEventId) && isConfigured(); + } + + // Sync a program's checked-in guests. Single-flighted per program. Returns + // { status, total, checkedIn, upserted, removed }. `status` is one of + // 'ok' | 'truncated' | 'empty_guard' | 'error:' | 'not_configured'. + async syncProgram(program) { + if (!this.isActive(program)) { + return { status: 'not_configured', total: 0, checkedIn: 0, upserted: 0, removed: 0 }; + } + const programId = program.id; + if (inFlight.has(programId)) return inFlight.get(programId); + + const promise = runSync(program).finally(() => inFlight.delete(programId)); + inFlight.set(programId, promise); + return promise; + } +} + +export default new LumaSyncService(); diff --git a/server/api/services/luma.client.js b/server/api/services/luma.client.js new file mode 100644 index 0000000..3fed650 --- /dev/null +++ b/server/api/services/luma.client.js @@ -0,0 +1,119 @@ +// Thin, read-only client for the Luma public API (https://docs.luma.com). +// +// We use it to mirror an event's *checked-in* guests into program_signups so +// the submission gate can verify "only checked-in attendees may submit" without +// us hosting a CSV of attendee data. The Luma API has no per-email lookup, so we +// page the full guest list (`get-guests`) and filter locally. +// +// Verified against a live event (evt-…, 464 guests, 10 pages @100, ~4.4s sweep): +// - auth header is `x-luma-api-key` +// - Luma's edge (Cloudflare) returns 403 for requests with a default runtime +// User-Agent, so we ALWAYS send an explicit one. This is not cosmetic. +// - check-in is `entry.checked_in_at` OR any `entry.event_tickets[].checked_in_at` +// - response shape: { entries: [...], has_more, next_cursor }, cursor via +// `pagination_cursor`, page size via `pagination_limit` (we use 100). + +const BASE = 'https://public-api.luma.com/v1'; +const USER_AGENT = 'stadium-webzero/1.0'; +const PAGE_LIMIT = 100; +const MAX_PAGES = 100; // ~10k guests; guards against a bad cursor looping forever +const REQUEST_TIMEOUT_MS = 8000; +const RETRY_BACKOFF_MS = 750; + +const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); + +function apiKey() { + const k = process.env.LUMA_API_KEY; + return typeof k === 'string' && k.trim() ? k.trim() : null; +} + +// Whether the server is configured to talk to Luma at all. The gate stays in +// CSV/test-email mode until this is true, so an unconfigured deploy can't lock +// everyone out. +export function isConfigured() { + return apiKey() !== null; +} + +// Normalize a Luma guest entry to the minimal shape we persist. We deliberately +// keep only email + name (not Luma's full payload) to minimize stored PII. +export function normalizeGuest(entry) { + const email = String(entry?.email || entry?.user_email || '').trim().toLowerCase(); + const name = entry?.name || entry?.user_name || null; + const tickets = Array.isArray(entry?.event_tickets) ? entry.event_tickets : []; + const checkedIn = Boolean(entry?.checked_in_at) || tickets.some((t) => Boolean(t?.checked_in_at)); + return { email, name, checkedIn, approvalStatus: entry?.approval_status ?? null }; +} + +// Fetch one page. One retry on timeout / 429 / 5xx with a short backoff; any +// other non-2xx fails fast (a 4xx won't fix itself on retry). +async function getPage(eventId, cursor, key) { + const url = new URL(`${BASE}/event/get-guests`); + url.searchParams.set('event_api_id', eventId); + url.searchParams.set('pagination_limit', String(PAGE_LIMIT)); + if (cursor) url.searchParams.set('pagination_cursor', cursor); + + let lastErr; + for (let attempt = 0; attempt < 2; attempt += 1) { + if (attempt > 0) await sleep(RETRY_BACKOFF_MS); + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS); + try { + const res = await fetch(url, { + headers: { 'x-luma-api-key': key, 'User-Agent': USER_AGENT, Accept: 'application/json' }, + signal: controller.signal, + }); + if (res.status === 429 || res.status >= 500) { + lastErr = new Error(`Luma get-guests transient ${res.status}`); + continue; + } + if (!res.ok) { + throw new Error(`Luma get-guests failed: ${res.status}`); + } + return await res.json(); + } catch (err) { + lastErr = err; + // Retry once on abort (timeout); otherwise bail after the loop. + if (err?.name !== 'AbortError' && attempt > 0) throw err; + } finally { + clearTimeout(timer); + } + } + throw lastErr || new Error('Luma get-guests failed'); +} + +// Page the full guest list. Returns { guests, pages, truncated }. `truncated` +// means we hit MAX_PAGES before Luma said it was done — the caller must treat a +// truncated result as untrustworthy and NOT overwrite a good cache with it. +export async function fetchAllGuests(eventId) { + const key = apiKey(); + if (!key) throw new Error('LUMA_API_KEY not set'); + if (!eventId) throw new Error('eventId required'); + + const guests = []; + let cursor = null; + for (let page = 0; page < MAX_PAGES; page += 1) { + // eslint-disable-next-line no-await-in-loop -- cursor pagination is inherently serial + const data = await getPage(eventId, cursor, key); + const entries = Array.isArray(data?.entries) ? data.entries : []; + for (const entry of entries) { + const guest = normalizeGuest(entry); + if (guest.email) guests.push(guest); + } + if (!data?.has_more || !data?.next_cursor) { + return { guests, pages: page + 1, truncated: false }; + } + cursor = data.next_cursor; + } + return { guests, pages: MAX_PAGES, truncated: true }; +} + +// Convenience: the checked-in subset plus totals, for the sync service. +export async function fetchCheckedIn(eventId) { + const { guests, pages, truncated } = await fetchAllGuests(eventId); + return { + total: guests.length, + checkedIn: guests.filter((g) => g.checkedIn), + pages, + truncated, + }; +} diff --git a/server/api/utils/validation.js b/server/api/utils/validation.js index 72acf00..234f679 100644 --- a/server/api/utils/validation.js +++ b/server/api/utils/validation.js @@ -565,6 +565,11 @@ export const validateProgram = (data, { partial = false } = {}) => { return { valid: false, error: 'eventUrl must start with http:// or https://' }; } } + if (has('lumaEventId') && data.lumaEventId !== null && data.lumaEventId !== '') { + if (typeof data.lumaEventId !== 'string' || data.lumaEventId.length > 100) { + return { valid: false, error: 'lumaEventId must be a string (max 100 characters)' }; + } + } if (has('content')) { const c = validateProgramContent(data.content); if (!c.valid) return c; diff --git a/supabase/migrations/20260615000000_program_luma_event.sql b/supabase/migrations/20260615000000_program_luma_event.sql new file mode 100644 index 0000000..04e0414 --- /dev/null +++ b/supabase/migrations/20260615000000_program_luma_event.sql @@ -0,0 +1,15 @@ +-- Luma API guest verification. +-- +-- A program with luma_event_id set has its checked-in guest list mirrored from +-- the Luma public API into program_signups (source='luma_api') instead of an +-- admin uploading a CSV. The submission gate verifies the submitter's email +-- against that synced set. We store the last sync time + status so the admin UI +-- can show "synced N min ago / ✓/✗" and the gate can decide when to lazily +-- refresh (TTL). +-- +-- Additive and idempotent — safe to re-run. + +ALTER TABLE programs + ADD COLUMN IF NOT EXISTS luma_event_id TEXT, + ADD COLUMN IF NOT EXISTS last_guest_sync_at TIMESTAMPTZ, + ADD COLUMN IF NOT EXISTS last_guest_sync_status TEXT; From 43f6f86ff0c5766231b758d206c36db5991ecf44 Mon Sep 17 00:00:00 2001 From: sacha <23283108+sacha-l@users.noreply.github.com> Date: Tue, 16 Jun 2026 00:01:24 +0200 Subject: [PATCH 2/3] feat(luma): add LUMA_GATE_MODE (checked_in default | approved) for pre-event dry runs Lets the gate accept approved registrants before an event starts (nobody is checked in yet) so the submit flow can be validated end-to-end. Default stays checked_in. Renames the client/sync seam to fetchEligibleGuests. --- server/.env.example | 7 ++++++ .../__tests__/luma-sync.service.test.js | 22 +++++++++---------- server/api/services/luma-sync.service.js | 17 +++++++++----- server/api/services/luma.client.js | 19 +++++++++++++--- 4 files changed, 46 insertions(+), 19 deletions(-) diff --git a/server/.env.example b/server/.env.example index d2d3a76..e2433ee 100644 --- a/server/.env.example +++ b/server/.env.example @@ -65,6 +65,13 @@ SUBMIT_TEST_EMAILS= # CSV / SUBMIT_TEST_EMAILS gate behavior. LUMA_API_KEY= +# Which Luma guests count as eligible to submit. 'checked_in' (default) requires +# a physical check-in (in-person events). 'approved' accepts any approved +# registrant — use it to dry-run the submit flow BEFORE an event starts, when +# nobody is checked in yet. +# LUMA_GATE_MODE=approved +LUMA_GATE_MODE= + # Public base URL of the client, used to build links in outbound email (e.g. the # program-admin onboarding link). Defaults to the prod URL if unset. FRONTEND_URL=https://stadium.joinwebzero.com diff --git a/server/api/services/__tests__/luma-sync.service.test.js b/server/api/services/__tests__/luma-sync.service.test.js index c2989e0..dcbf77d 100644 --- a/server/api/services/__tests__/luma-sync.service.test.js +++ b/server/api/services/__tests__/luma-sync.service.test.js @@ -1,7 +1,7 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; vi.mock('../luma.client.js', () => ({ - fetchCheckedIn: vi.fn(), + fetchEligibleGuests: vi.fn(), isConfigured: vi.fn(() => true), })); vi.mock('../../repositories/program-signup.repository.js', () => ({ @@ -45,13 +45,13 @@ describe('lumaSyncService.syncProgram', () => { luma.isConfigured.mockReturnValue(false); const r = await service.syncProgram(PROGRAM); expect(r.status).toBe('not_configured'); - expect(luma.fetchCheckedIn).not.toHaveBeenCalled(); + expect(luma.fetchEligibleGuests).not.toHaveBeenCalled(); }); it('ok: mirrors the checked-in set and stamps state', async () => { - luma.fetchCheckedIn.mockResolvedValue({ + luma.fetchEligibleGuests.mockResolvedValue({ total: 3, - checkedIn: [G('a@x.io'), G('b@x.io')], + eligible: [G('a@x.io'), G('b@x.io')], truncated: false, }); signupRepo.replaceLumaGuests.mockResolvedValue({ upserted: 2, removed: 0 }); @@ -66,7 +66,7 @@ describe('lumaSyncService.syncProgram', () => { }); it('empty_guard: a 0-result sweep does NOT wipe a non-empty cache', async () => { - luma.fetchCheckedIn.mockResolvedValue({ total: 0, checkedIn: [], truncated: false }); + luma.fetchEligibleGuests.mockResolvedValue({ total: 0, eligible: [], truncated: false }); signupRepo.countBySource.mockResolvedValue(120); // cache currently has 120 const r = await service.syncProgram(PROGRAM); @@ -79,7 +79,7 @@ describe('lumaSyncService.syncProgram', () => { }); it('0 check-ins is allowed to write when the cache is also empty (pre-event)', async () => { - luma.fetchCheckedIn.mockResolvedValue({ total: 50, checkedIn: [], truncated: false }); + luma.fetchEligibleGuests.mockResolvedValue({ total: 50, eligible: [], truncated: false }); signupRepo.countBySource.mockResolvedValue(0); const r = await service.syncProgram(PROGRAM); @@ -88,14 +88,14 @@ describe('lumaSyncService.syncProgram', () => { }); it('truncated: never overwrites on a partial sweep', async () => { - luma.fetchCheckedIn.mockResolvedValue({ total: 9999, checkedIn: [G('a@x.io')], truncated: true }); + luma.fetchEligibleGuests.mockResolvedValue({ total: 9999, eligible: [G('a@x.io')], truncated: true }); const r = await service.syncProgram(PROGRAM); expect(r.status).toBe('truncated'); expect(signupRepo.replaceLumaGuests).not.toHaveBeenCalled(); }); it('error: surfaces error: and still stamps the timestamp', async () => { - luma.fetchCheckedIn.mockRejectedValue(new Error('Luma 429')); + luma.fetchEligibleGuests.mockRejectedValue(new Error('Luma 429')); const r = await service.syncProgram(PROGRAM); expect(r.status).toMatch(/^error:/); expect(programRepo.setGuestSyncState).toHaveBeenCalledWith( @@ -106,11 +106,11 @@ describe('lumaSyncService.syncProgram', () => { it('single-flight: concurrent syncs share one Luma sweep', async () => { let resolve; - luma.fetchCheckedIn.mockReturnValue(new Promise((r) => { resolve = r; })); + luma.fetchEligibleGuests.mockReturnValue(new Promise((r) => { resolve = r; })); const p1 = service.syncProgram(PROGRAM); const p2 = service.syncProgram(PROGRAM); - resolve({ total: 1, checkedIn: [G('a@x.io')], truncated: false }); + resolve({ total: 1, eligible: [G('a@x.io')], truncated: false }); await Promise.all([p1, p2]); - expect(luma.fetchCheckedIn).toHaveBeenCalledTimes(1); + expect(luma.fetchEligibleGuests).toHaveBeenCalledTimes(1); }); }); diff --git a/server/api/services/luma-sync.service.js b/server/api/services/luma-sync.service.js index ef8d0db..ab00c99 100644 --- a/server/api/services/luma-sync.service.js +++ b/server/api/services/luma-sync.service.js @@ -4,10 +4,17 @@ // ("Sync now"). No background timer — freshness only matters at submit time, // which is exactly when the gate triggers a sync. See luma.client.js. -import { fetchCheckedIn, isConfigured } from './luma.client.js'; +import { fetchEligibleGuests, isConfigured } from './luma.client.js'; import signupRepository from '../repositories/program-signup.repository.js'; import programRepository from '../repositories/program.repository.js'; +// Which guests count as gate-eligible. Defaults to physical check-in; set +// LUMA_GATE_MODE=approved to accept approved registrants (e.g. pre-event dry +// runs, before anyone has checked in). +function gateMode() { + return process.env.LUMA_GATE_MODE === 'approved' ? 'approved' : 'checked_in'; +} + // One in-flight sync per program. Concurrent submitters that all miss the cache // share a single Luma sweep instead of stampeding the API. const inFlight = new Map(); @@ -23,19 +30,19 @@ async function runSync(program) { let status; let result = { total: 0, checkedIn: 0, upserted: 0, removed: 0 }; try { - const { total, checkedIn, truncated } = await fetchCheckedIn(eventId); + const { total, eligible, truncated } = await fetchEligibleGuests(eventId, gateMode()); if (truncated) { // Partial sweep — never trust it enough to overwrite a good cache. status = 'truncated'; - } else if (checkedIn.length === 0 && (await signupRepository.countBySource(programId, 'luma_api')) > 0) { + } else if (eligible.length === 0 && (await signupRepository.countBySource(programId, 'luma_api')) > 0) { // Sanity guard: a 0-result sweep against a non-empty cache is almost // certainly a wrong event id / revoked key / Luma blip. Keep last-good. status = 'empty_guard'; } else { - const { upserted, removed } = await signupRepository.replaceLumaGuests(programId, checkedIn); + const { upserted, removed } = await signupRepository.replaceLumaGuests(programId, eligible); status = 'ok'; - result = { total, checkedIn: checkedIn.length, upserted, removed }; + result = { total, checkedIn: eligible.length, upserted, removed }; } } catch (err) { status = shortError(err); diff --git a/server/api/services/luma.client.js b/server/api/services/luma.client.js index 3fed650..3a51c1f 100644 --- a/server/api/services/luma.client.js +++ b/server/api/services/luma.client.js @@ -107,12 +107,25 @@ export async function fetchAllGuests(eventId) { return { guests, pages: MAX_PAGES, truncated: true }; } -// Convenience: the checked-in subset plus totals, for the sync service. -export async function fetchCheckedIn(eventId) { +// Gate modes. 'checked_in' (default, in-person events) requires a physical +// check-in. 'approved' accepts any approved registrant — used to dry-run the +// flow BEFORE an event starts (nobody is checked in yet), or if a program ever +// wants "registered counts" rather than "checked-in counts". +export const GATE_MODES = ['checked_in', 'approved']; + +function isEligible(guest, mode) { + // Checked-in guests are always eligible; 'approved' additionally lets through + // anyone whose registration is approved. + if (guest.checkedIn) return true; + return mode === 'approved' && guest.approvalStatus === 'approved'; +} + +// The gate-eligible subset plus totals, for the sync service. +export async function fetchEligibleGuests(eventId, mode = 'checked_in') { const { guests, pages, truncated } = await fetchAllGuests(eventId); return { total: guests.length, - checkedIn: guests.filter((g) => g.checkedIn), + eligible: guests.filter((g) => isEligible(g, mode)), pages, truncated, }; From 78417f5407c1d44f21ff52f2ce1f50610d2769af Mon Sep 17 00:00:00 2001 From: sacha <23283108+sacha-l@users.noreply.github.com> Date: Tue, 16 Jun 2026 00:29:36 +0200 Subject: [PATCH 3/3] feat(luma): show 'verifying check-in' state on submit so the gate wait reads as progress The submit POST triggers a server-side Luma sync on a cold cache (~3-4s for the first/just-checked-in submitter; instant once warm). Relabel the in-flight button to VERIFYING CHECK-IN and add a reassurance line so the wait doesn't read as frozen. --- client/src/components/program/SubmitProjectModal.tsx | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/client/src/components/program/SubmitProjectModal.tsx b/client/src/components/program/SubmitProjectModal.tsx index fa7bf67..d4db2c7 100644 --- a/client/src/components/program/SubmitProjectModal.tsx +++ b/client/src/components/program/SubmitProjectModal.tsx @@ -174,6 +174,11 @@ export function SubmitProjectModal({ className="hidden" /> + {submitting && ( +

+ CHECKING YOU AGAINST THE LUMA GUEST LIST. THIS CAN TAKE A FEW SECONDS. +

+ )}