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,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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user