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:
@@ -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 doesn’t 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>
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
)
|
||||
@@ -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);
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user