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
+434
View File
@@ -0,0 +1,434 @@
<script setup lang="ts">
// Tier 2 Block H — day-of check-in. The host opens this on their phone
// at the venue, taps "Start scanning", and points the camera at each
// guest's QR. Live arrivals counter updates in place; walk-ins go in
// via the side button.
//
// QR decoding uses jsQR loaded from a CDN at runtime so we don't bloat
// the regular bundle. The camera is requested only when the scanner
// is open and stopped on close.
interface CheckInRecord {
id: string
guest_id: string
checked_in_at: string
arrival_count: number
walk_in: boolean
notes?: string | null
}
interface Summary {
arrived_headcount: number
expected_headcount: number
guests_checked_in: number
}
interface ListResponse {
check_ins: CheckInRecord[]
summary: Summary
}
const props = defineProps<{
eventId: string
yourRole?: 'owner' | 'editor' | 'viewer' | null
}>()
const canEdit = computed(() => props.yourRole === 'owner' || props.yourRole === 'editor')
const loading = ref(true)
const error = ref<string | null>(null)
const checkIns = ref<CheckInRecord[]>([])
const summary = ref<Summary>({ arrived_headcount: 0, expected_headcount: 0, guests_checked_in: 0 })
// Toast (success / error per scan).
type Toast = { kind: 'success' | 'warn' | 'error'; text: string }
const toast = ref<Toast | null>(null)
let toastTimer: ReturnType<typeof setTimeout> | null = null
function showToast(t: Toast, ms = 3000) {
toast.value = t
if (toastTimer) clearTimeout(toastTimer)
toastTimer = setTimeout(() => { toast.value = null }, ms)
}
async function refresh() {
loading.value = true
try {
const data = await useApi<ListResponse>(`/events/${props.eventId}/check-ins`)
checkIns.value = data.check_ins || []
summary.value = data.summary
} catch (e: any) {
error.value = useErrMessage(e, 'Could not load check-ins')
} finally {
loading.value = false
}
}
onMounted(refresh)
// --- camera + scanner ---
const scannerOpen = ref(false)
const videoRef = ref<HTMLVideoElement | null>(null)
const canvasRef = ref<HTMLCanvasElement | null>(null)
let mediaStream: MediaStream | null = null
let scanLoopHandle: number | null = null
const lastScanAt = ref(0)
const recentlyScanned = ref<Set<string>>(new Set())
async function ensureJsQR() {
// Load jsQR from a CDN once. We attach it to window.jsQR so the
// scanner loop can call it. If it's already loaded (host opens +
// closes the scanner repeatedly) reuse the existing copy.
if (typeof window === 'undefined') return null
const w = window as any
if (w.jsQR) return w.jsQR
await new Promise<void>((resolve, reject) => {
const s = document.createElement('script')
s.src = 'https://cdn.jsdelivr.net/npm/jsqr@1.4.0/dist/jsQR.js'
s.onload = () => resolve()
s.onerror = () => reject(new Error('Could not load QR scanner script'))
document.head.appendChild(s)
})
return w.jsQR
}
async function openScanner() {
if (!canEdit.value) return
try {
const jsQR = await ensureJsQR()
if (!jsQR) {
showToast({ kind: 'error', text: 'QR scanner is not available in this browser.' })
return
}
mediaStream = await navigator.mediaDevices.getUserMedia({
video: { facingMode: 'environment' },
audio: false,
})
scannerOpen.value = true
await nextTick()
if (videoRef.value) {
videoRef.value.srcObject = mediaStream
await videoRef.value.play()
scanLoop(jsQR)
}
} catch (e: any) {
showToast({ kind: 'error', text: e?.message || 'Could not start the camera.' })
}
}
function closeScanner() {
if (scanLoopHandle !== null) {
cancelAnimationFrame(scanLoopHandle)
scanLoopHandle = null
}
if (mediaStream) {
mediaStream.getTracks().forEach((t) => t.stop())
mediaStream = null
}
scannerOpen.value = false
recentlyScanned.value.clear()
}
function scanLoop(jsQR: any) {
const video = videoRef.value
const canvas = canvasRef.value
if (!video || !canvas) return
const ctx = canvas.getContext('2d', { willReadFrequently: true })
if (!ctx) return
const tick = () => {
if (!scannerOpen.value || !videoRef.value || !canvasRef.value) return
if (videoRef.value.readyState === videoRef.value.HAVE_ENOUGH_DATA) {
canvas.width = videoRef.value.videoWidth
canvas.height = videoRef.value.videoHeight
ctx.drawImage(videoRef.value, 0, 0, canvas.width, canvas.height)
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height)
const code = jsQR(imageData.data, imageData.width, imageData.height, {
inversionAttempts: 'dontInvert',
})
if (code && code.data && shouldProcessScan(code.data)) {
handleScannedPayload(code.data)
}
}
scanLoopHandle = requestAnimationFrame(tick)
}
tick()
}
// shouldProcessScan throttles + dedupes scans. The camera grabs ~30
// frames per second; without a guard we'd POST 30x for one paper QR.
function shouldProcessScan(payload: string): boolean {
const now = Date.now()
if (now - lastScanAt.value < 1500) return false
if (recentlyScanned.value.has(payload)) return false
lastScanAt.value = now
recentlyScanned.value.add(payload)
// Expire from the dedupe set after 10s so a guest who legitimately
// re-scans (e.g. host re-pointing camera at the same code by accident)
// doesn't get stuck.
setTimeout(() => recentlyScanned.value.delete(payload), 10_000)
return true
}
async function handleScannedPayload(payload: string) {
try {
const res = await useApi<{ guest: any; summary: Summary; check_in: CheckInRecord }>(
`/events/${props.eventId}/check-in`,
{ method: 'POST', body: { qr_payload: payload, arrival_count: 1 } },
)
summary.value = res.summary
checkIns.value = [res.check_in, ...checkIns.value]
showToast({ kind: 'success', text: `${res.guest?.name || 'Guest'} is in.` })
if (navigator.vibrate) navigator.vibrate(80)
} catch (e: any) {
const status = e?.response?.status ?? e?.statusCode
if (status === 409) {
showToast({ kind: 'warn', text: 'Already checked in.' })
} else if (status === 410) {
showToast({ kind: 'error', text: 'This code has expired.' })
} else if (status === 400) {
showToast({ kind: 'error', text: 'That doesnt look like one of your guests.' })
} else {
showToast({ kind: 'error', text: useErrMessage(e, 'Could not check in') })
}
}
}
onUnmounted(closeScanner)
// --- walk-in ---
const walkInOpen = ref(false)
const walkInForm = reactive({
name: '',
email: '',
arrival_count: 1,
notes: '',
})
const walkInSubmitting = ref(false)
function openWalkIn() {
walkInForm.name = ''
walkInForm.email = ''
walkInForm.arrival_count = 1
walkInForm.notes = ''
walkInOpen.value = true
}
async function submitWalkIn() {
if (!walkInForm.name.trim()) return
walkInSubmitting.value = true
try {
const res = await useApi<{ guest: any; summary: Summary; check_in: CheckInRecord }>(
`/events/${props.eventId}/walk-ins`,
{
method: 'POST',
body: {
name: walkInForm.name.trim(),
email: walkInForm.email.trim() || undefined,
arrival_count: walkInForm.arrival_count,
notes: walkInForm.notes.trim() || undefined,
},
},
)
summary.value = res.summary
checkIns.value = [res.check_in, ...checkIns.value]
showToast({ kind: 'success', text: `${res.guest?.name || 'Walk-in'} added.` })
walkInOpen.value = false
} catch (e: any) {
showToast({ kind: 'error', text: useErrMessage(e, 'Could not add walk-in') })
} finally {
walkInSubmitting.value = false
}
}
function fmtTime(iso?: string | null) {
if (!iso) return ''
try { return new Date(iso).toLocaleTimeString() } catch { return iso }
}
const arrivalPct = computed(() => {
const exp = summary.value.expected_headcount
const arr = summary.value.arrived_headcount
if (exp === 0) return 0
return Math.min(100, Math.round((arr / exp) * 100))
})
</script>
<template>
<section class="card">
<header class="mb-3 flex items-center justify-between">
<div>
<h2 class="text-lg font-semibold">Check-in</h2>
<p class="text-xs text-zinc-500">Door scanner and live arrivals.</p>
</div>
<div v-if="canEdit" class="flex items-center gap-2">
<button class="btn-ghost text-sm" @click="openWalkIn">+ Walk-in</button>
<button class="btn-primary text-sm" @click="openScanner">
<svg class="mr-1 inline-block h-4 w-4" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
<path d="M4 4a1 1 0 011-1h3a1 1 0 010 2H6v2a1 1 0 11-2 0V4zm0 12a1 1 0 011-1h2v-2a1 1 0 112 0v3a1 1 0 01-1 1H5a1 1 0 01-1-1zM16 4a1 1 0 00-1-1h-3a1 1 0 100 2h2v2a1 1 0 102 0V4zm0 12a1 1 0 01-1 1h-3a1 1 0 110-2h2v-2a1 1 0 112 0v3z" />
</svg>
Start scanning
</button>
</div>
</header>
<p v-if="error" class="mb-3 text-sm text-red-400">{{ error }}</p>
<p v-if="loading" class="text-sm text-zinc-500">Loading</p>
<div v-else class="space-y-5">
<!-- Live arrivals widget. The big number is what a host glances
at "are we full yet?" -->
<div class="rounded-lg border border-brand-700/40 bg-brand-500/[0.06] p-4">
<div class="flex items-baseline justify-between">
<div>
<p class="text-xs font-medium uppercase tracking-widest text-brand-400">Arrived</p>
<p class="mt-1 text-4xl font-semibold tabular-nums text-brand-200">
{{ summary.arrived_headcount }}
<span class="text-base font-normal text-zinc-400">of {{ summary.expected_headcount }}</span>
</p>
<p class="mt-1 text-xs text-zinc-400">
{{ summary.guests_checked_in }}
{{ summary.guests_checked_in === 1 ? 'guest' : 'guests' }} checked in
</p>
</div>
<div class="text-right">
<p class="text-3xl font-semibold text-brand-200 tabular-nums">{{ arrivalPct }}%</p>
<p class="text-xs text-zinc-500">of expected</p>
</div>
</div>
<div class="mt-3 h-2 w-full overflow-hidden rounded-full bg-zinc-900">
<div class="h-full rounded-full bg-brand-500 transition-all" :style="{ width: arrivalPct + '%' }"></div>
</div>
</div>
<!-- Recent arrivals keeps a host's confidence that scans are
actually landing. -->
<div>
<p class="mb-2 text-xs font-medium uppercase tracking-wider text-zinc-500">Recent arrivals</p>
<p v-if="!checkIns.length" class="text-sm text-zinc-500">No one's checked in yet.</p>
<ul v-else class="space-y-1.5">
<li
v-for="c in checkIns.slice(0, 12)"
:key="c.id"
class="flex items-center justify-between rounded-md border border-zinc-800 bg-zinc-950 px-3 py-2 text-sm"
>
<div class="min-w-0 flex-1">
<p class="text-zinc-100">
Guest checked in
<span v-if="c.walk_in" class="ml-1 rounded-full bg-amber-900/30 px-1.5 py-0.5 text-[10px] font-medium uppercase tracking-wide text-amber-300">walk-in</span>
</p>
<p class="text-xs text-zinc-500">
{{ fmtTime(c.checked_in_at) }} · party of {{ c.arrival_count }}
</p>
</div>
</li>
</ul>
</div>
</div>
<!-- Scanner modal. Camera is only mounted while open; closing tears
down the stream so the camera light goes off. -->
<Teleport to="body">
<div
v-if="scannerOpen"
class="fixed inset-0 z-50 flex flex-col bg-black"
@click.self="closeScanner"
>
<div class="flex items-center justify-between px-4 py-3 text-white">
<p class="text-sm font-medium">Scanning</p>
<button class="text-sm text-white/70 hover:text-white" @click="closeScanner">Close</button>
</div>
<div class="relative flex-1 overflow-hidden">
<video ref="videoRef" playsinline class="h-full w-full object-cover"></video>
<canvas ref="canvasRef" class="hidden"></canvas>
<!-- A simple frame to guide the host. -->
<div class="pointer-events-none absolute inset-0 flex items-center justify-center">
<div class="h-64 w-64 rounded-lg border-2 border-brand-500/80 shadow-[0_0_0_9999px_rgba(0,0,0,0.55)]"></div>
</div>
</div>
<div class="px-4 py-3 text-center text-xs text-white/70">
Hold the guest's code in the frame. Toasts pop when each scan registers.
</div>
</div>
</Teleport>
<!-- Walk-in modal. -->
<Teleport to="body">
<div
v-if="walkInOpen"
class="fixed inset-0 z-40 flex items-center justify-center bg-black/60 p-4 backdrop-blur-sm"
@click.self="walkInOpen = false"
>
<div
role="dialog"
aria-modal="true"
class="w-full max-w-md rounded-lg border border-zinc-800 bg-zinc-900 p-5 shadow-2xl"
>
<h3 class="mb-1 text-base font-semibold">Walk-in</h3>
<p class="mb-4 text-xs text-zinc-500">
Adds them to your guest list and marks them checked in.
</p>
<form class="space-y-3" @submit.prevent="submitWalkIn">
<div>
<label class="label">Name</label>
<input v-model="walkInForm.name" class="input" required />
</div>
<div>
<label class="label">Email (optional)</label>
<input v-model="walkInForm.email" type="email" class="input" />
</div>
<div>
<label class="label">Party size</label>
<input
v-model.number="walkInForm.arrival_count"
type="number"
min="1"
max="20"
class="input"
/>
</div>
<div>
<label class="label">Notes (optional)</label>
<input v-model="walkInForm.notes" class="input" placeholder="Friend of the bride" />
</div>
<div class="flex items-center justify-end gap-2 pt-2">
<button
type="button"
class="text-sm text-zinc-400 hover:text-zinc-200"
:disabled="walkInSubmitting"
@click="walkInOpen = false"
>Cancel</button>
<button
type="submit"
class="btn-primary text-sm"
:disabled="walkInSubmitting || !walkInForm.name.trim()"
>{{ walkInSubmitting ? 'Adding' : 'Add walk-in' }}</button>
</div>
</form>
</div>
</div>
</Teleport>
<!-- Toast. -->
<Teleport to="body">
<Transition
enter-active-class="transition duration-200 ease-out"
enter-from-class="translate-y-2 opacity-0"
enter-to-class="translate-y-0 opacity-100"
leave-active-class="transition duration-200 ease-in"
leave-from-class="translate-y-0 opacity-100"
leave-to-class="translate-y-2 opacity-0"
>
<button
v-if="toast"
type="button"
class="fixed bottom-6 right-6 z-[60] flex max-w-sm items-center gap-2 rounded-lg border px-4 py-3 text-left text-sm shadow-lg backdrop-blur"
:class="toast.kind === 'success' ? 'border-brand-700/60 bg-brand-950/90 text-brand-100'
: toast.kind === 'warn' ? 'border-amber-700/60 bg-amber-950/90 text-amber-100'
: 'border-red-800/60 bg-red-950/90 text-red-100'"
@click="toast = null"
>{{ toast.text }}</button>
</Transition>
</Teleport>
</section>
</template>