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