feat(tier2): day-of check-in — Block H
QR codes on RSVP confirmations, a phone-friendly door scanner, walk-in
support, and a live arrivals widget that updates over WebSocket. Closes
the final Tier 2 block.
Schema (migration 0013)
- check_ins (id, guest_id UNIQUE, checked_in_at, checked_in_by,
arrival_count, notes, walk_in). UNIQUE on guest_id is the
double-check-in guard at the DB layer; signature validation lives
in the QR JWT.
QR JWT
- internal/auth/checkin_qr.go: CheckInQRSigner mints {event_id,
guest_id, exp} payloads with the platform's existing HMAC secret.
Issue() extends expiry to eventDate+24h so a QR minted weeks in
advance still scans on the day. Parse() distinguishes
ErrExpiredJWT from generic ErrInvalidJWT so the API can render a
friendlier 410.
- Unit tests cover round-trip, wrong-secret rejection, expiry
detection, and short-secret refusal at construction time.
Domain + storage
- domain.CheckIn + CheckInSummary
- storage.CheckInRepo: Record (returns ErrAlreadyCheckedIn on the
unique violation), ListByEvent, Summary (arrived headcount,
expected headcount, guests-checked-in count), GuestBelongsToEvent
(belt-and-braces guard against a forged JWT pointing at a
different event's guest).
API
- GET /access/{token} now embeds a check_in payload (raw JWT + a
base64-encoded PNG via skip2/go-qrcode) for attending RSVPs, so
the confirmation page can render the code straight into an <img>.
- POST /events/{id}/check-in — editor+. Validates the QR JWT,
refuses cross-event payloads (400), refuses expired ones (410),
records the row, broadcasts check_in.recorded over the existing
WS hub so the live dashboard updates.
- POST /events/{id}/walk-ins — editor+. Creates the guest + check-in
in one logical op for a door-add who wasn't on the original list.
- GET /events/{id}/check-ins — viewer+. Returns the list and the
summary together so the dashboard widget hydrates in one call.
Frontend
- New CheckInCard.vue: live arrivals widget ("47 of 60 · 78%" plus
a progress bar), recent-arrivals list, Walk-in button, and a
"Start scanning" button that opens a full-screen camera modal.
jsQR loaded from CDN on first open (no bundler dep). Scan
throttling + dedupe prevents the 30fps camera loop from POSTing
N times per paper QR. Successful scan vibrates the phone.
Duplicate (409) → "Already checked in" toast; expired (410) →
"This code has expired"; foreign-event (400) → "doesn't look
like one of your guests".
- New "Check-in" tab on the event-detail page, between
Communications and Branding.
- RSVP confirmation card + revisit card both surface a "Save for
the day" / "Your door code" QR block for attending guests. The
PNG ships pre-rendered from the API so the frontend doesn't need
its own QR library.
- The submit flow now refetches /access after a successful POST so
the QR appears immediately on first submit, not just on revisit.
Tests
- Backend unit tests for the QR signer (round-trip, wrong-secret,
expired, short-secret rejection).
- Integration: TestCheckInHappyPath (scan -> 200, double-scan ->
409, summary reflects arrival), TestCheckInRejectsForeignQR
(event A's JWT can't be used on event B), TestWalkInCreatesGuest
AndCheckIn (door-add creates both rows).
- Full integration suite passes (188.3s, 41 tests / 80+ subtests).
Tier 2 is complete: Blocks A through H all shipped.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -69,8 +69,8 @@ const loading = ref(true)
|
||||
// (Collaborators + Branding, configured once) → Analytics (results,
|
||||
// checked periodically). The two action-y tabs anchor the ends; setup
|
||||
// clusters in the middle.
|
||||
type EventTab = 'guests' | 'collaborators' | 'communications' | 'branding' | 'analytics' | 'gate'
|
||||
const validTabs: EventTab[] = ['guests', 'collaborators', 'communications', 'branding', 'analytics', 'gate']
|
||||
type EventTab = 'guests' | 'collaborators' | 'communications' | 'branding' | 'analytics' | 'gate' | 'checkin'
|
||||
const validTabs: EventTab[] = ['guests', 'collaborators', 'communications', 'branding', 'analytics', 'gate', 'checkin']
|
||||
function tabFromHash(): EventTab {
|
||||
if (import.meta.client) {
|
||||
const h = window.location.hash.replace('#', '') as EventTab
|
||||
@@ -786,6 +786,7 @@ function checkLabel(band?: string): string {
|
||||
{ id: 'guests', label: 'Guests' },
|
||||
{ id: 'collaborators', label: 'Collaborators' },
|
||||
{ id: 'communications', label: 'Communications' },
|
||||
{ id: 'checkin', label: 'Check-in' },
|
||||
{ id: 'branding', label: 'Branding' },
|
||||
{ id: 'analytics', label: 'Analytics' },
|
||||
{ id: 'gate', label: 'Gate' },
|
||||
@@ -1218,6 +1219,11 @@ function checkLabel(band?: string): string {
|
||||
<CommunicationsCard :event-id="eventId" :your-role="event.your_role" />
|
||||
</div>
|
||||
|
||||
<!-- Check-in (Tier 2 Block H). Door scanner + live arrivals. -->
|
||||
<div v-if="activeTab === 'checkin' && event" class="mt-2">
|
||||
<CheckInCard :event-id="eventId" :your-role="event.your_role" />
|
||||
</div>
|
||||
|
||||
<!-- Gate (Tier 2 Block G). The user-facing rebrand of the fraud
|
||||
detector: strictness presets + trusted networks + decision
|
||||
history, with the technical sliders/CIDR jargon tucked behind
|
||||
|
||||
@@ -37,6 +37,9 @@ interface AccessResponse {
|
||||
can_request_edit_link?: boolean
|
||||
calendar?: CalendarLinks
|
||||
branding?: BrandingPayload | null
|
||||
// Block H — per-guest QR code, only populated for attending RSVPs.
|
||||
// qr_image is a data: URL ready for an <img src>.
|
||||
check_in?: { qr: string; qr_image: string } | null
|
||||
}
|
||||
|
||||
interface RSVPSubmitResponse {
|
||||
@@ -172,6 +175,18 @@ async function submit() {
|
||||
}
|
||||
}
|
||||
editing.value = false
|
||||
|
||||
// Re-fetch access so the response carries the Block H check-in QR
|
||||
// (the submit endpoint doesn't render the QR — only access does).
|
||||
// Best-effort: a failure here doesn't roll back the successful
|
||||
// RSVP, the guest just won't see the QR until they refresh.
|
||||
try {
|
||||
const path = editNonce.value
|
||||
? `/access/${token}?edit=${encodeURIComponent(editNonce.value)}`
|
||||
: `/access/${token}`
|
||||
const fresh = await useApi<AccessResponse>(path)
|
||||
access.value = fresh
|
||||
} catch { /* non-fatal */ }
|
||||
} catch (e: any) {
|
||||
// BLOCK band returns 403 with the fraud decision; surface it the same
|
||||
// way the first-submit path does.
|
||||
@@ -312,6 +327,29 @@ const submitLabel = computed(() => {
|
||||
class="mt-5 border-t border-zinc-800 pt-4"
|
||||
/>
|
||||
|
||||
<!-- Block H — door QR for attending guests. Shown right under
|
||||
"you're confirmed" because that's when the guest is most
|
||||
likely to screenshot or save the email. -->
|
||||
<div
|
||||
v-if="access?.check_in && result.rsvp.response === 'attending'"
|
||||
class="mt-5 border-t border-zinc-800 pt-4"
|
||||
>
|
||||
<p class="mb-2 text-xs font-medium uppercase tracking-widest text-brand-500">
|
||||
Save for the day
|
||||
</p>
|
||||
<p class="mb-3 text-sm text-zinc-300">
|
||||
Show this code at the door for a quick check-in. Screenshot it now,
|
||||
or look out for the same code on your confirmation email.
|
||||
</p>
|
||||
<div class="flex items-center justify-center rounded-md border border-zinc-800 bg-white p-3">
|
||||
<img
|
||||
:src="access.check_in.qr_image"
|
||||
alt="Your check-in QR code"
|
||||
class="h-44 w-44"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="!editLimitReached" class="mt-5 flex items-center justify-between gap-3 border-t border-zinc-800 pt-4">
|
||||
<p class="text-xs text-zinc-500">
|
||||
Need to change something? You have {{ editsRemaining }}
|
||||
@@ -344,6 +382,25 @@ const submitLabel = computed(() => {
|
||||
class="mb-4 border-t border-zinc-800 pt-4"
|
||||
/>
|
||||
|
||||
<div
|
||||
v-if="access?.check_in && existing.response === 'attending'"
|
||||
class="mb-4 border-t border-zinc-800 pt-4"
|
||||
>
|
||||
<p class="mb-2 text-xs font-medium uppercase tracking-widest text-brand-500">
|
||||
Your door code
|
||||
</p>
|
||||
<p class="mb-3 text-sm text-zinc-300">
|
||||
Show this at the entrance for a quick check-in on the day.
|
||||
</p>
|
||||
<div class="flex items-center justify-center rounded-md border border-zinc-800 bg-white p-3">
|
||||
<img
|
||||
:src="access.check_in.qr_image"
|
||||
alt="Your check-in QR code"
|
||||
class="h-44 w-44"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="!editLimitReached" class="flex items-center justify-between gap-3 border-t border-zinc-800 pt-4">
|
||||
<p class="text-xs text-zinc-500">
|
||||
{{ editsRemaining }} {{ editsRemaining === 1 ? 'edit' : 'edits' }} remaining.
|
||||
|
||||
Reference in New Issue
Block a user