Files
Kwaku Danso 003a320690 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>
2026-05-20 17:20:46 +01:00

118 lines
3.8 KiB
Go

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
}