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:
Kwaku Danso
2026-05-20 17:20:46 +01:00
parent dc840bfc14
commit 003a320690
15 changed files with 1394 additions and 2 deletions
+57
View File
@@ -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.