003a320690
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>
26 lines
1.1 KiB
SQL
26 lines
1.1 KiB
SQL
-- 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);
|