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
+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)
}
}