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>
+8 -2
View File
@@ -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
+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.
+1
View File
@@ -77,6 +77,7 @@ require (
github.com/redis/go-redis/v9 v9.19.0 // indirect
github.com/shirou/gopsutil/v4 v4.26.3 // indirect
github.com/sirupsen/logrus v1.9.4 // indirect
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e // indirect
github.com/stretchr/testify v1.11.1 // indirect
github.com/stripe/stripe-go/v82 v82.5.1 // indirect
github.com/tklauser/go-sysconf v0.3.16 // indirect
+2
View File
@@ -151,6 +151,8 @@ github.com/shirou/gopsutil/v4 v4.26.3 h1:2ESdQt90yU3oXF/CdOlRCJxrP+Am1aBYubTMTfx
github.com/shirou/gopsutil/v4 v4.26.3/go.mod h1:LZ6ewCSkBqUpvSOf+LsTGnRinC6iaNUNMGBtDkJBaLQ=
github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w=
github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g=
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e h1:MRM5ITcdelLK2j1vwZ3Je0FKVCfqOLp5zO6trqMLYs0=
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e/go.mod h1:XV66xRDqSt+GTGFMVlhk3ULuV0y9ZmzeVGR4mloJI3M=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.5.3 h1:jmXUvGomnU1o3W/V5h2VEradbpJDwGrzugQQvL0POH4=
github.com/stretchr/objx v0.5.3/go.mod h1:rDQraq+vQZU7Fde9LOZLr8Tax6zZvy4kuNKF+QYS+U0=
+298
View File
@@ -0,0 +1,298 @@
package api
import (
"encoding/base64"
"encoding/json"
"errors"
"log/slog"
"net/http"
"strings"
"github.com/google/uuid"
"github.com/skip2/go-qrcode"
"github.com/alchemistkay/guestguard/internal/auth"
"github.com/alchemistkay/guestguard/internal/domain"
"github.com/alchemistkay/guestguard/internal/storage"
)
// checkInHandler is the host-facing surface for Tier 2 Block H: scan QR
// codes at the door, record arrivals, register walk-ins, drive the
// live arrivals counter on the dashboard.
type checkInHandler struct {
logger *slog.Logger
events *storage.EventRepo
guests *storage.GuestRepo
collabs *storage.CollaboratorRepo
repo *storage.CheckInRepo
qrSigner *auth.CheckInQRSigner
hub *Hub
}
// --- record a check-in (QR-scanner POST) ---
type recordCheckInRequest struct {
QRPayload string `json:"qr_payload"`
ArrivalCount int `json:"arrival_count"`
Notes string `json:"notes"`
}
type recordCheckInResponse struct {
CheckIn domain.CheckIn `json:"check_in"`
Guest *domain.Guest `json:"guest"`
Summary domain.CheckInSummary `json:"summary"`
}
// POST /events/{id}/check-in — editor+. The scanner UI submits the
// decoded QR payload plus how many people in the party actually walked
// in. Duplicate scans surface as 409 with a friendly "already in"
// message; that's how the scanner UI knows to flash orange instead of
// green.
func (h *checkInHandler) record(w http.ResponseWriter, r *http.Request) {
hostID, ok := hostFromContext(w, r)
if !ok {
return
}
eventID, ok := parseIDParam(w, r, "id")
if !ok {
return
}
if _, _, ok := requireRole(w, r, h.events, h.collabs, eventID, hostID, domain.RoleEditor); !ok {
return
}
var req recordCheckInRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid json")
return
}
req.QRPayload = strings.TrimSpace(req.QRPayload)
if req.QRPayload == "" {
writeError(w, http.StatusBadRequest, "qr_payload is required")
return
}
claims, err := h.qrSigner.Parse(req.QRPayload)
if err != nil {
switch {
case errors.Is(err, auth.ErrExpiredJWT):
writeError(w, http.StatusGone, "this QR has expired")
default:
writeError(w, http.StatusBadRequest, "invalid QR")
}
return
}
if claims.EventID != eventID {
// JWT was issued for a different event. The scanner may have
// roamed; the host should switch event pages.
writeError(w, http.StatusBadRequest, "this QR belongs to a different event")
return
}
// Sanity: the guest still belongs to this event (host may have
// removed them after issuing the QR).
belongs, err := h.repo.GuestBelongsToEvent(r.Context(), claims.GuestID, eventID)
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to verify guest")
return
}
if !belongs {
writeError(w, http.StatusNotFound, "guest is no longer on this event")
return
}
ci, err := h.repo.Record(r.Context(), storage.RecordCheckInParams{
GuestID: claims.GuestID,
CheckedInBy: hostID,
ArrivalCount: req.ArrivalCount,
Notes: req.Notes,
WalkIn: false,
})
if err != nil {
if errors.Is(err, domain.ErrAlreadyCheckedIn) {
writeError(w, http.StatusConflict, "this guest is already checked in")
return
}
h.logger.Error("record check-in", "err", err)
writeError(w, http.StatusInternalServerError, "failed to record check-in")
return
}
guest, _ := h.guests.Get(r.Context(), claims.GuestID)
summary, _ := h.repo.Summary(r.Context(), eventID)
// Broadcast to the live arrivals dashboard.
h.broadcast(eventID, "check_in.recorded", map[string]any{
"check_in": ci,
"guest_id": ci.GuestID,
"guest_name": nameOf(guest),
"arrived_headcount": summary.ArrivedHeadcount,
"expected_headcount": summary.ExpectedHeadcount,
"guests_checked_in": summary.GuestsCheckedIn,
})
writeJSON(w, http.StatusOK, recordCheckInResponse{
CheckIn: *ci,
Guest: guest,
Summary: summary,
})
}
// --- walk-ins ---
type walkInRequest struct {
Name string `json:"name"`
Email string `json:"email"`
Phone string `json:"phone"`
ArrivalCount int `json:"arrival_count"`
Notes string `json:"notes"`
}
// POST /events/{id}/walk-ins — editor+. Creates the guest + check-in
// row in one logical operation so the door volunteer doesn't have to
// fumble between two screens for a party-crasher who was meant to be
// invited.
func (h *checkInHandler) walkIn(w http.ResponseWriter, r *http.Request) {
hostID, ok := hostFromContext(w, r)
if !ok {
return
}
eventID, ok := parseIDParam(w, r, "id")
if !ok {
return
}
if _, _, ok := requireRole(w, r, h.events, h.collabs, eventID, hostID, domain.RoleEditor); !ok {
return
}
var req walkInRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid json")
return
}
req.Name = strings.TrimSpace(req.Name)
if req.Name == "" {
writeError(w, http.StatusBadRequest, "name is required")
return
}
if req.ArrivalCount <= 0 {
req.ArrivalCount = 1
}
emailPtr := optStr(req.Email)
phonePtr := optStr(req.Phone)
guest, err := h.guests.Create(r.Context(), storage.CreateGuestParams{
EventID: eventID,
Name: req.Name,
Email: emailPtr,
Phone: phonePtr,
PlusOnes: req.ArrivalCount - 1,
})
if err != nil {
h.logger.Error("create walk-in guest", "err", err)
writeError(w, http.StatusInternalServerError, "failed to create guest")
return
}
ci, err := h.repo.Record(r.Context(), storage.RecordCheckInParams{
GuestID: guest.ID,
CheckedInBy: hostID,
ArrivalCount: req.ArrivalCount,
Notes: req.Notes,
WalkIn: true,
})
if err != nil {
h.logger.Error("record walk-in check-in", "err", err)
writeError(w, http.StatusInternalServerError, "failed to record check-in")
return
}
summary, _ := h.repo.Summary(r.Context(), eventID)
h.broadcast(eventID, "check_in.recorded", map[string]any{
"check_in": ci,
"guest_id": ci.GuestID,
"guest_name": guest.Name,
"walk_in": true,
"arrived_headcount": summary.ArrivedHeadcount,
"expected_headcount": summary.ExpectedHeadcount,
"guests_checked_in": summary.GuestsCheckedIn,
})
writeJSON(w, http.StatusCreated, recordCheckInResponse{
CheckIn: *ci,
Guest: guest,
Summary: summary,
})
}
// --- list + summary ---
// GET /events/{id}/check-ins — viewer+.
func (h *checkInHandler) list(w http.ResponseWriter, r *http.Request) {
hostID, ok := hostFromContext(w, r)
if !ok {
return
}
eventID, ok := parseIDParam(w, r, "id")
if !ok {
return
}
if _, _, ok := requireRole(w, r, h.events, h.collabs, eventID, hostID, domain.RoleViewer); !ok {
return
}
rows, err := h.repo.ListByEvent(r.Context(), eventID)
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to list check-ins")
return
}
summary, _ := h.repo.Summary(r.Context(), eventID)
writeJSON(w, http.StatusOK, map[string]any{
"check_ins": rows,
"summary": summary,
})
}
// --- helpers ---
func (h *checkInHandler) broadcast(eventID uuid.UUID, evtType string, payload any) {
if h.hub == nil {
return
}
body, err := json.Marshal(payload)
if err != nil {
h.logger.Warn("marshal check-in ws event", "err", err)
return
}
h.hub.Broadcast(WSEvent{
Type: evtType,
EventID: eventID,
Payload: body,
})
}
func optStr(s string) *string {
t := strings.TrimSpace(s)
if t == "" {
return nil
}
return &t
}
func nameOf(g *domain.Guest) string {
if g == nil {
return ""
}
return g.Name
}
// renderQRPNG converts a JWT-shaped string into a base64-encoded PNG
// data URL the frontend can drop straight into an <img src>. Used by
// the access response so a successful RSVP comes back with the guest's
// scannable code already rendered.
func renderQRPNG(payload string) (string, error) {
png, err := qrcode.Encode(payload, qrcode.Medium, 320)
if err != nil {
return "", err
}
return "data:image/png;base64," + base64.StdEncoding.EncodeToString(png), nil
}
+29
View File
@@ -43,6 +43,7 @@ type Server struct {
uploads *uploadHandler
security *securityHandler
messages *messageHandler
checkIns *checkInHandler
}
type ServerDeps struct {
@@ -105,6 +106,16 @@ func NewServer(deps ServerDeps) (*Server, error) {
allowlistRepo := storage.NewAllowlistRepo(deps.DB)
editNonces := newEditNonceStore(deps.Redis)
messageRepo := storage.NewMessageRepo(deps.DB)
checkInRepo := storage.NewCheckInRepo(deps.DB)
// Tier 2 Block H — QR JWT signer reuses the platform's JWT secret
// so production secrets management already covers it. TTL=6h is the
// minimum lifetime; Issue() extends to eventDate+24h on demand so
// codes minted weeks in advance still scan on the day.
checkInQRSigner, err := auth.NewCheckInQRSigner(deps.JWTSecret, deps.JWTIssuer, 6*time.Hour)
if err != nil {
return nil, err
}
feedbackRepo := storage.NewFeedbackRepo(deps.DB)
// Branding image store. Empty UploadsDir leaves it nil and the upload
@@ -199,6 +210,7 @@ func NewServer(deps ServerDeps) (*Server, error) {
branding: brandingRepo,
editNonces: editNonces,
emails: emails,
checkInQR: checkInQRSigner,
gen: auth.NewGenerator(),
ttl: deps.TokenTTL,
pub: deps.AccessPublisher,
@@ -283,6 +295,15 @@ func NewServer(deps ServerDeps) (*Server, error) {
collabs: collabRepo,
repo: messageRepo,
},
checkIns: &checkInHandler{
logger: deps.Logger,
events: eventRepo,
guests: guestRepo,
collabs: collabRepo,
repo: checkInRepo,
qrSigner: checkInQRSigner,
hub: hub,
},
collabs: &collaboratorHandler{
logger: deps.Logger,
events: eventRepo,
@@ -414,6 +435,14 @@ func (s *Server) Handler() http.Handler {
mux.Handle("DELETE /events/{id}/messages/{message_id}",
authed(http.HandlerFunc(s.messages.cancel)))
// Block H — day-of check-in.
mux.Handle("POST /events/{id}/check-in",
authed(rl("checkin_record", 1000, time.Hour, userIDKey, http.HandlerFunc(s.checkIns.record))))
mux.Handle("POST /events/{id}/walk-ins",
authed(rl("checkin_walk_in", 500, time.Hour, userIDKey, http.HandlerFunc(s.checkIns.walkIn))))
mux.Handle("GET /events/{id}/check-ins",
authed(http.HandlerFunc(s.checkIns.list)))
// Block D — event branding. Reads are viewer+; PUT is editor+. The
// upload endpoint is gated by auth only (any signed-in user can mint
// an image URL; the URL is no use without an event they can edit
+33
View File
@@ -38,6 +38,7 @@ type tokenHandler struct {
branding *storage.BrandingRepo
editNonces *editNonceStore
emails auth.EmailSender
checkInQR *auth.CheckInQRSigner
gen *auth.Generator
ttl time.Duration
pub accessPublisher
@@ -438,6 +439,18 @@ type accessResponse struct {
// logo / cover image. Nil when the host hasn't customised yet — the
// frontend falls back to defaults. (Tier 2 Block D.)
Branding *domain.Branding `json:"branding,omitempty"`
// CheckIn carries the per-guest QR code data when the RSVP is
// "attending". The frontend renders the PNG straight into an
// <img src>; the guest screenshots it or saves the confirmation
// email for door scanning on the day. (Tier 2 Block H.)
CheckIn *checkInQRPayload `json:"check_in,omitempty"`
}
// checkInQRPayload bundles the QR JWT + the rendered PNG so the
// frontend doesn't need a QR library of its own.
type checkInQRPayload struct {
QR string `json:"qr"` // raw JWT — what the scanner POSTs back
QRImage string `json:"qr_image"` // data:image/png;base64,... ready for <img>
}
// GET /access/{token} — validate token, log the access attempt, publish to NATS.
@@ -561,6 +574,25 @@ func (h *tokenHandler) access(w http.ResponseWriter, r *http.Request) {
}
}
// QR code for the door (Tier 2 Block H). We only mint the JWT when
// the visible RSVP is "attending" — there's no point handing a
// check-in code to someone who replied no. The QR is bound to
// (event_id, guest_id) and only valid through the event window.
var checkInPayload *checkInQRPayload
if rsvpPayload != nil && rsvpPayload.Response == domain.RSVPAttending && h.checkInQR != nil {
now := time.Now().UTC()
qrJWT, _, err := h.checkInQR.Issue(event.ID, guest.ID, event.EventDate, now)
if err == nil {
if png, err2 := renderQRPNG(qrJWT); err2 == nil {
checkInPayload = &checkInQRPayload{QR: qrJWT, QRImage: png}
} else {
h.logger.Warn("render qr png", "err", err2, "guest_id", guest.ID)
}
} else {
h.logger.Warn("issue qr jwt", "err", err, "guest_id", guest.ID)
}
}
writeJSON(w, http.StatusOK, accessResponse{
Guest: guest,
Event: event,
@@ -571,6 +603,7 @@ func (h *tokenHandler) access(w http.ResponseWriter, r *http.Request) {
CanRequestEditLink: canRequestEditLink,
Calendar: h.calendarLinks(event, raw),
Branding: brandingPayload,
CheckIn: checkInPayload,
})
}
+117
View File
@@ -0,0 +1,117 @@
package auth
import (
"errors"
"fmt"
"time"
"github.com/golang-jwt/jwt/v5"
"github.com/google/uuid"
)
// CheckInQR is the JWT payload stamped into each guest's QR code on
// their RSVP confirmation. Tier 2 Block H.
//
// Why a separate JWT (vs reusing the access JWT or the invitation
// token):
// - We want a long-lived "good for the whole event window" code that
// persists even after the guest's session expires. A 6-hour JWT
// with explicit (event_id, guest_id) is the right shape.
// - The QR ends up on paper / wallpapers / screenshots; a
// guessable-shape token like the invitation slug would be too
// easy to fudge.
// - The signed payload lets the scanner verify offline (in theory)
// and the API verify cheaply (HMAC-SHA256, no DB lookup for
// authentication of the QR — DB only confirms guest membership
// before recording).
type CheckInQR struct {
EventID uuid.UUID `json:"event_id"`
GuestID uuid.UUID `json:"guest_id"`
jwt.RegisteredClaims
}
// CheckInQRSigner mints and verifies the QR JWTs. Same HMAC secret as
// the rest of the auth surface (GG_JWT_SECRET) so production secrets
// management already covers this code path.
type CheckInQRSigner struct {
secret []byte
issuer string
parser *jwt.Parser
ttl time.Duration
}
// NewCheckInQRSigner expects the same shape of secret as NewJWTSigner —
// at least 32 bytes. ttl bounds how far ahead of the event the QR
// stays valid; the API caller typically passes (eventDate.Sub(now) + 24h)
// so the code is good through any reasonable lateness window.
func NewCheckInQRSigner(secret string, issuer string, ttl time.Duration) (*CheckInQRSigner, error) {
if len(secret) < 32 {
return nil, fmt.Errorf("jwt secret must be at least 32 bytes")
}
if ttl <= 0 {
return nil, fmt.Errorf("qr ttl must be positive")
}
return &CheckInQRSigner{
secret: []byte(secret),
issuer: issuer,
ttl: ttl,
parser: jwt.NewParser(
jwt.WithValidMethods([]string{jwt.SigningMethodHS256.Alg()}),
jwt.WithIssuer(issuer),
jwt.WithExpirationRequired(),
),
}, nil
}
// Issue mints a QR JWT good until the larger of (a) the event date + 24h
// or (b) the signer's default TTL. The caller passes eventDate so the
// QR lifetime matches the actual event window — a wedding three months
// out shouldn't get a code that expires before the day.
func (s *CheckInQRSigner) Issue(eventID, guestID uuid.UUID, eventDate time.Time, now time.Time) (string, time.Time, error) {
exp := now.Add(s.ttl)
if dayAfter := eventDate.Add(24 * time.Hour); dayAfter.After(exp) {
exp = dayAfter
}
claims := CheckInQR{
EventID: eventID,
GuestID: guestID,
RegisteredClaims: jwt.RegisteredClaims{
Issuer: s.issuer,
Subject: guestID.String(),
IssuedAt: jwt.NewNumericDate(now),
NotBefore: jwt.NewNumericDate(now.Add(-1 * time.Second)),
ExpiresAt: jwt.NewNumericDate(exp),
ID: uuid.NewString(),
},
}
tok := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
signed, err := tok.SignedString(s.secret)
if err != nil {
return "", time.Time{}, err
}
return signed, exp, nil
}
// Parse verifies the JWT and returns the bound (event_id, guest_id).
// Returns ErrExpiredJWT specifically when the only issue is expiry, so
// the API can render a friendlier message; everything else maps to
// ErrInvalidJWT.
func (s *CheckInQRSigner) Parse(raw string) (*CheckInQR, error) {
claims := &CheckInQR{}
tok, err := s.parser.ParseWithClaims(raw, claims, func(t *jwt.Token) (any, error) {
return s.secret, nil
})
if err != nil {
if errors.Is(err, jwt.ErrTokenExpired) {
return nil, ErrExpiredJWT
}
return nil, ErrInvalidJWT
}
if !tok.Valid {
return nil, ErrInvalidJWT
}
if claims.EventID == uuid.Nil || claims.GuestID == uuid.Nil {
return nil, ErrInvalidJWT
}
return claims, nil
}
+76
View File
@@ -0,0 +1,76 @@
package auth
import (
"errors"
"testing"
"time"
"github.com/google/uuid"
)
const qrTestSecret = "test-secret-must-be-at-least-32-bytes-long-xx"
func TestCheckInQR_RoundTrip(t *testing.T) {
s, err := NewCheckInQRSigner(qrTestSecret, "test", 6*time.Hour)
if err != nil {
t.Fatalf("new signer: %v", err)
}
eventID := uuid.New()
guestID := uuid.New()
now := time.Now().UTC()
tok, exp, err := s.Issue(eventID, guestID, now.Add(24*time.Hour), now)
if err != nil {
t.Fatalf("issue: %v", err)
}
if !exp.After(now) {
t.Fatalf("expiry should be in the future: %v vs now %v", exp, now)
}
claims, err := s.Parse(tok)
if err != nil {
t.Fatalf("parse: %v", err)
}
if claims.EventID != eventID || claims.GuestID != guestID {
t.Errorf("ids mismatch: got %v / %v, want %v / %v",
claims.EventID, claims.GuestID, eventID, guestID)
}
}
func TestCheckInQR_RejectsWrongSecret(t *testing.T) {
signerA, _ := NewCheckInQRSigner(qrTestSecret, "test", time.Hour)
signerB, _ := NewCheckInQRSigner("other-secret-must-be-at-least-32-bytes-xx", "test", time.Hour)
tok, _, err := signerA.Issue(uuid.New(), uuid.New(), time.Now().Add(time.Hour), time.Now())
if err != nil {
t.Fatalf("issue: %v", err)
}
if _, err := signerB.Parse(tok); !errors.Is(err, ErrInvalidJWT) {
t.Errorf("parse with wrong secret: want ErrInvalidJWT, got %v", err)
}
}
func TestCheckInQR_RejectsExpired(t *testing.T) {
s, _ := NewCheckInQRSigner(qrTestSecret, "test", time.Second)
// Use a "now" far enough in the past that even the eventDate+24h
// extension lands before the actual current time. Two days ago for
// both: expiry resolves to now-2d+1s OR now-2d+24h, the later wins,
// so the token expires at now-1d — still in the past.
pastNow := time.Now().UTC().Add(-48 * time.Hour)
eventDate := pastNow
tok, exp, err := s.Issue(uuid.New(), uuid.New(), eventDate, pastNow)
if err != nil {
t.Fatalf("issue: %v", err)
}
if exp.After(time.Now().UTC()) {
t.Fatalf("setup bug: expected exp %v in the past", exp)
}
if _, err := s.Parse(tok); !errors.Is(err, ErrExpiredJWT) {
t.Errorf("parse expired: want ErrExpiredJWT, got %v", err)
}
}
func TestCheckInQR_SecretTooShort(t *testing.T) {
if _, err := NewCheckInQRSigner("short", "test", time.Hour); err == nil {
t.Error("expected error for too-short secret")
}
}
+38
View File
@@ -0,0 +1,38 @@
package domain
import (
"errors"
"time"
"github.com/google/uuid"
)
// CheckIn records that a guest walked in. One row per guest (the UNIQUE
// constraint prevents double check-in); arrival_count captures how many
// people were actually in the party — guest + plus-ones who showed.
// walk_in tags door-adds that weren't on the original guest list.
// Tier 2 Block H.
type CheckIn struct {
ID uuid.UUID `json:"id"`
GuestID uuid.UUID `json:"guest_id"`
CheckedInAt time.Time `json:"checked_in_at"`
CheckedInBy *uuid.UUID `json:"checked_in_by,omitempty"`
ArrivalCount int `json:"arrival_count"`
Notes *string `json:"notes,omitempty"`
WalkIn bool `json:"walk_in"`
}
// CheckInSummary is the dashboard widget's data: total arrivals so far,
// and the original headcount goal (sum of attending + plus_ones).
type CheckInSummary struct {
ArrivedHeadcount int `json:"arrived_headcount"`
ExpectedHeadcount int `json:"expected_headcount"`
GuestsCheckedIn int `json:"guests_checked_in"`
}
var (
ErrAlreadyCheckedIn = errors.New("guest is already checked in")
ErrCheckInBadQR = errors.New("invalid QR payload")
ErrCheckInExpired = errors.New("QR has expired")
ErrCheckInMismatch = errors.New("QR belongs to a different event")
)
+116
View File
@@ -0,0 +1,116 @@
package storage
import (
"context"
"errors"
"fmt"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgconn"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/alchemistkay/guestguard/internal/domain"
)
// CheckInRepo holds the check_ins table. Tier 2 Block H.
type CheckInRepo struct {
pool *pgxpool.Pool
}
func NewCheckInRepo(db *DB) *CheckInRepo {
return &CheckInRepo{pool: db.Pool}
}
type RecordCheckInParams struct {
GuestID uuid.UUID
CheckedInBy uuid.UUID
ArrivalCount int
Notes string
WalkIn bool
}
// Record inserts a check-in. The UNIQUE on guest_id surfaces a
// double-check-in as domain.ErrAlreadyCheckedIn so the scanner UI can
// show a clear "already in" message instead of a generic 500.
func (r *CheckInRepo) Record(ctx context.Context, p RecordCheckInParams) (*domain.CheckIn, error) {
if p.ArrivalCount <= 0 {
p.ArrivalCount = 1
}
const q = `
INSERT INTO check_ins (guest_id, checked_in_by, arrival_count, notes, walk_in)
VALUES ($1, $2, $3, NULLIF($4, ''), $5)
RETURNING id, guest_id, checked_in_at, checked_in_by, arrival_count, notes, walk_in
`
var c domain.CheckIn
err := r.pool.QueryRow(ctx, q,
p.GuestID, p.CheckedInBy, p.ArrivalCount, p.Notes, p.WalkIn,
).Scan(&c.ID, &c.GuestID, &c.CheckedInAt, &c.CheckedInBy, &c.ArrivalCount, &c.Notes, &c.WalkIn)
if err != nil {
var pgErr *pgconn.PgError
if errors.As(err, &pgErr) && pgErr.Code == "23505" {
return nil, domain.ErrAlreadyCheckedIn
}
return nil, fmt.Errorf("record check-in: %w", err)
}
return &c, nil
}
// ListByEvent returns every check-in on an event, newest first. Powers
// the live arrivals list on the dashboard.
func (r *CheckInRepo) ListByEvent(ctx context.Context, eventID uuid.UUID) ([]domain.CheckIn, error) {
rows, err := r.pool.Query(ctx, `
SELECT c.id, c.guest_id, c.checked_in_at, c.checked_in_by,
c.arrival_count, c.notes, c.walk_in
FROM check_ins c
JOIN guests g ON g.id = c.guest_id
WHERE g.event_id = $1
ORDER BY c.checked_in_at DESC
`, eventID)
if err != nil {
return nil, err
}
defer rows.Close()
out := []domain.CheckIn{}
for rows.Next() {
var c domain.CheckIn
if err := rows.Scan(&c.ID, &c.GuestID, &c.CheckedInAt, &c.CheckedInBy,
&c.ArrivalCount, &c.Notes, &c.WalkIn); err != nil {
return nil, err
}
out = append(out, c)
}
return out, rows.Err()
}
// Summary returns the headcount totals: how many people walked in, and
// how many were expected (sum of attending RSVPs + their plus_ones).
func (r *CheckInRepo) Summary(ctx context.Context, eventID uuid.UUID) (domain.CheckInSummary, error) {
var s domain.CheckInSummary
err := r.pool.QueryRow(ctx, `
SELECT
COALESCE(SUM(c.arrival_count), 0) AS arrived_headcount,
(
SELECT COALESCE(SUM(1 + r.plus_ones), 0)
FROM rsvps r
JOIN guests g ON g.id = r.guest_id
WHERE g.event_id = $1 AND r.response = 'attending'
) AS expected_headcount,
COUNT(c.id) AS guests_checked_in
FROM check_ins c
JOIN guests g ON g.id = c.guest_id
WHERE g.event_id = $1
`, eventID).Scan(&s.ArrivedHeadcount, &s.ExpectedHeadcount, &s.GuestsCheckedIn)
return s, err
}
// GuestBelongsToEvent confirms a guest is on the event before we record
// their check-in. Belt-and-braces guard against a forged JWT pointing
// at a guest from a different event — the JWT layer already binds
// (event_id, guest_id) but a DB-level check is cheap insurance.
func (r *CheckInRepo) GuestBelongsToEvent(ctx context.Context, guestID, eventID uuid.UUID) (bool, error) {
var ok bool
err := r.pool.QueryRow(ctx,
`SELECT EXISTS (SELECT 1 FROM guests WHERE id = $1 AND event_id = $2)`,
guestID, eventID).Scan(&ok)
return ok, err
}
@@ -0,0 +1,2 @@
DROP INDEX IF EXISTS idx_checkins_event_time;
DROP TABLE IF EXISTS check_ins;
@@ -0,0 +1,25 @@
-- Tier 2 Block H — day-of check-in.
--
-- One row per guest who showed up. The UNIQUE on guest_id is the
-- double-check-in guard at the DB layer; the QR validation (JWT
-- signature + expiry + event match) lives in the API.
--
-- arrival_count records how many people actually walked in for this
-- guest (1 + plus-ones who showed). walk_in marks the row as a
-- door-add — a guest who wasn't on the original list. The QR payload
-- itself isn't stored; it's a derivable JWT.
CREATE TABLE IF NOT EXISTS check_ins (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
guest_id UUID NOT NULL UNIQUE REFERENCES guests(id) ON DELETE CASCADE,
checked_in_at TIMESTAMPTZ NOT NULL DEFAULT now(),
checked_in_by UUID REFERENCES users(id) ON DELETE SET NULL,
arrival_count INTEGER NOT NULL DEFAULT 1,
notes TEXT,
walk_in BOOLEAN NOT NULL DEFAULT FALSE
);
-- "How's the door looking?" — the live arrivals widget runs this
-- against (event_id, checked_in_at) so the index covers the ORDER BY.
CREATE INDEX IF NOT EXISTS idx_checkins_event_time
ON check_ins(checked_in_at DESC);
+158
View File
@@ -0,0 +1,158 @@
//go:build integration
package integration_test
import (
"context"
"fmt"
"net/http"
"testing"
"time"
"github.com/google/uuid"
"github.com/alchemistkay/guestguard/internal/auth"
)
// Tier 2 Block H — day-of check-in.
type checkInResp struct {
CheckIn struct {
ID uuid.UUID `json:"id"`
GuestID uuid.UUID `json:"guest_id"`
ArrivalCount int `json:"arrival_count"`
WalkIn bool `json:"walk_in"`
} `json:"check_in"`
Summary struct {
ArrivedHeadcount int `json:"arrived_headcount"`
ExpectedHeadcount int `json:"expected_headcount"`
GuestsCheckedIn int `json:"guests_checked_in"`
} `json:"summary"`
}
// TestCheckInHappyPath walks the door-flow: host scans a guest's QR,
// arrival is recorded, the live summary reflects the new total. A
// duplicate scan returns 409 with a friendly message so the scanner UI
// can flash orange instead of green.
func TestCheckInHappyPath(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test in -short mode")
}
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
t.Cleanup(cancel)
srv, db, _, token := setupAuthedAPI(t, ctx)
eventID := createEvent(t, srv.URL, token, "Check-In Day", "checkin-day")
// Seed a guest with an attending RSVP so the QR JWT we mint is
// realistic. Plus-ones=1 so expected headcount = 2.
seedAnalyticsGuest(t, ctx, db.Pool, eventID, "Sam", 1, "attending", 1, true)
// Look the guest up directly so we know their id.
var guestID uuid.UUID
must(t, db.Pool.QueryRow(ctx, `SELECT id FROM guests WHERE event_id=$1 LIMIT 1`, eventID).Scan(&guestID),
"load guest id")
// Mint a QR JWT with the test's known JWT secret + issuer (same the
// API was constructed with via setupAuthedAPI).
signer, err := auth.NewCheckInQRSigner(testJWTSecret, testJWTIssuer, 6*time.Hour)
must(t, err, "qr signer")
qrJWT, _, err := signer.Issue(eventID, guestID, time.Now().Add(24*time.Hour), time.Now())
must(t, err, "mint qr")
// First scan: 200, arrival count=2 (guest + 1 plus-one walked in).
var ok checkInResp
postJSONAuthed(t,
fmt.Sprintf("%s/events/%s/check-in", srv.URL, eventID),
token,
map[string]any{"qr_payload": qrJWT, "arrival_count": 2},
http.StatusOK, &ok)
if ok.CheckIn.ArrivalCount != 2 {
t.Errorf("arrival_count: got %d want 2", ok.CheckIn.ArrivalCount)
}
if ok.Summary.ArrivedHeadcount != 2 {
t.Errorf("arrived_headcount: got %d want 2", ok.Summary.ArrivedHeadcount)
}
if ok.Summary.ExpectedHeadcount != 2 {
t.Errorf("expected_headcount: got %d want 2", ok.Summary.ExpectedHeadcount)
}
// Second scan with same QR: 409 conflict, no second row written.
assertStatus(t, http.MethodPost,
fmt.Sprintf("%s/events/%s/check-in", srv.URL, eventID),
token,
map[string]any{"qr_payload": qrJWT, "arrival_count": 1},
http.StatusConflict)
}
// TestCheckInRejectsForeignQR confirms a JWT minted for one event can't
// be used to check someone in on another. The path 404s rather than
// 403 to avoid leaking the existence of the foreign event.
func TestCheckInRejectsForeignQR(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test in -short mode")
}
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
t.Cleanup(cancel)
srv, db, _, token := setupAuthedAPI(t, ctx)
eventA := createEvent(t, srv.URL, token, "A", "evt-a")
eventB := createEvent(t, srv.URL, token, "B", "evt-b")
seedAnalyticsGuest(t, ctx, db.Pool, eventA, "Sam A", 0, "attending", 0, true)
var guestA uuid.UUID
must(t, db.Pool.QueryRow(ctx, `SELECT id FROM guests WHERE event_id=$1`, eventA).Scan(&guestA), "load guest")
signer, err := auth.NewCheckInQRSigner(testJWTSecret, testJWTIssuer, time.Hour)
must(t, err, "qr signer")
// JWT bound to event A.
qrA, _, err := signer.Issue(eventA, guestA, time.Now().Add(24*time.Hour), time.Now())
must(t, err, "mint qr")
// Posting that QR to event B should be rejected.
assertStatus(t, http.MethodPost,
fmt.Sprintf("%s/events/%s/check-in", srv.URL, eventB),
token,
map[string]any{"qr_payload": qrA, "arrival_count": 1},
http.StatusBadRequest)
}
// TestWalkInCreatesGuestAndCheckIn confirms a door-add registers a new
// guest plus their check-in in one logical operation.
func TestWalkInCreatesGuestAndCheckIn(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test in -short mode")
}
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
t.Cleanup(cancel)
srv, db, _, token := setupAuthedAPI(t, ctx)
eventID := createEvent(t, srv.URL, token, "Walk-Ins", "walk-ins")
var out checkInResp
postJSONAuthed(t,
fmt.Sprintf("%s/events/%s/walk-ins", srv.URL, eventID),
token,
map[string]any{
"name": "Door Crash",
"arrival_count": 3,
"notes": "Friend of bride",
},
http.StatusCreated, &out)
if !out.CheckIn.WalkIn {
t.Errorf("walk_in flag should be true")
}
if out.CheckIn.ArrivalCount != 3 {
t.Errorf("arrival_count: got %d want 3", out.CheckIn.ArrivalCount)
}
// Sanity: a new guest row exists with plus_ones = 2 (3 in the
// party, one of whom is the guest, plus two more).
var plusOnes int
must(t, db.Pool.QueryRow(ctx,
`SELECT plus_ones FROM guests WHERE event_id = $1`, eventID,
).Scan(&plusOnes), "load guest")
if plusOnes != 2 {
t.Errorf("guest plus_ones: got %d want 2", plusOnes)
}
}