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
+298
View File
@@ -0,0 +1,298 @@
package api
import (
"encoding/base64"
"encoding/json"
"errors"
"log/slog"
"net/http"
"strings"
"github.com/google/uuid"
"github.com/skip2/go-qrcode"
"github.com/alchemistkay/guestguard/internal/auth"
"github.com/alchemistkay/guestguard/internal/domain"
"github.com/alchemistkay/guestguard/internal/storage"
)
// checkInHandler is the host-facing surface for Tier 2 Block H: scan QR
// codes at the door, record arrivals, register walk-ins, drive the
// live arrivals counter on the dashboard.
type checkInHandler struct {
logger *slog.Logger
events *storage.EventRepo
guests *storage.GuestRepo
collabs *storage.CollaboratorRepo
repo *storage.CheckInRepo
qrSigner *auth.CheckInQRSigner
hub *Hub
}
// --- record a check-in (QR-scanner POST) ---
type recordCheckInRequest struct {
QRPayload string `json:"qr_payload"`
ArrivalCount int `json:"arrival_count"`
Notes string `json:"notes"`
}
type recordCheckInResponse struct {
CheckIn domain.CheckIn `json:"check_in"`
Guest *domain.Guest `json:"guest"`
Summary domain.CheckInSummary `json:"summary"`
}
// POST /events/{id}/check-in — editor+. The scanner UI submits the
// decoded QR payload plus how many people in the party actually walked
// in. Duplicate scans surface as 409 with a friendly "already in"
// message; that's how the scanner UI knows to flash orange instead of
// green.
func (h *checkInHandler) record(w http.ResponseWriter, r *http.Request) {
hostID, ok := hostFromContext(w, r)
if !ok {
return
}
eventID, ok := parseIDParam(w, r, "id")
if !ok {
return
}
if _, _, ok := requireRole(w, r, h.events, h.collabs, eventID, hostID, domain.RoleEditor); !ok {
return
}
var req recordCheckInRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid json")
return
}
req.QRPayload = strings.TrimSpace(req.QRPayload)
if req.QRPayload == "" {
writeError(w, http.StatusBadRequest, "qr_payload is required")
return
}
claims, err := h.qrSigner.Parse(req.QRPayload)
if err != nil {
switch {
case errors.Is(err, auth.ErrExpiredJWT):
writeError(w, http.StatusGone, "this QR has expired")
default:
writeError(w, http.StatusBadRequest, "invalid QR")
}
return
}
if claims.EventID != eventID {
// JWT was issued for a different event. The scanner may have
// roamed; the host should switch event pages.
writeError(w, http.StatusBadRequest, "this QR belongs to a different event")
return
}
// Sanity: the guest still belongs to this event (host may have
// removed them after issuing the QR).
belongs, err := h.repo.GuestBelongsToEvent(r.Context(), claims.GuestID, eventID)
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to verify guest")
return
}
if !belongs {
writeError(w, http.StatusNotFound, "guest is no longer on this event")
return
}
ci, err := h.repo.Record(r.Context(), storage.RecordCheckInParams{
GuestID: claims.GuestID,
CheckedInBy: hostID,
ArrivalCount: req.ArrivalCount,
Notes: req.Notes,
WalkIn: false,
})
if err != nil {
if errors.Is(err, domain.ErrAlreadyCheckedIn) {
writeError(w, http.StatusConflict, "this guest is already checked in")
return
}
h.logger.Error("record check-in", "err", err)
writeError(w, http.StatusInternalServerError, "failed to record check-in")
return
}
guest, _ := h.guests.Get(r.Context(), claims.GuestID)
summary, _ := h.repo.Summary(r.Context(), eventID)
// Broadcast to the live arrivals dashboard.
h.broadcast(eventID, "check_in.recorded", map[string]any{
"check_in": ci,
"guest_id": ci.GuestID,
"guest_name": nameOf(guest),
"arrived_headcount": summary.ArrivedHeadcount,
"expected_headcount": summary.ExpectedHeadcount,
"guests_checked_in": summary.GuestsCheckedIn,
})
writeJSON(w, http.StatusOK, recordCheckInResponse{
CheckIn: *ci,
Guest: guest,
Summary: summary,
})
}
// --- walk-ins ---
type walkInRequest struct {
Name string `json:"name"`
Email string `json:"email"`
Phone string `json:"phone"`
ArrivalCount int `json:"arrival_count"`
Notes string `json:"notes"`
}
// POST /events/{id}/walk-ins — editor+. Creates the guest + check-in
// row in one logical operation so the door volunteer doesn't have to
// fumble between two screens for a party-crasher who was meant to be
// invited.
func (h *checkInHandler) walkIn(w http.ResponseWriter, r *http.Request) {
hostID, ok := hostFromContext(w, r)
if !ok {
return
}
eventID, ok := parseIDParam(w, r, "id")
if !ok {
return
}
if _, _, ok := requireRole(w, r, h.events, h.collabs, eventID, hostID, domain.RoleEditor); !ok {
return
}
var req walkInRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid json")
return
}
req.Name = strings.TrimSpace(req.Name)
if req.Name == "" {
writeError(w, http.StatusBadRequest, "name is required")
return
}
if req.ArrivalCount <= 0 {
req.ArrivalCount = 1
}
emailPtr := optStr(req.Email)
phonePtr := optStr(req.Phone)
guest, err := h.guests.Create(r.Context(), storage.CreateGuestParams{
EventID: eventID,
Name: req.Name,
Email: emailPtr,
Phone: phonePtr,
PlusOnes: req.ArrivalCount - 1,
})
if err != nil {
h.logger.Error("create walk-in guest", "err", err)
writeError(w, http.StatusInternalServerError, "failed to create guest")
return
}
ci, err := h.repo.Record(r.Context(), storage.RecordCheckInParams{
GuestID: guest.ID,
CheckedInBy: hostID,
ArrivalCount: req.ArrivalCount,
Notes: req.Notes,
WalkIn: true,
})
if err != nil {
h.logger.Error("record walk-in check-in", "err", err)
writeError(w, http.StatusInternalServerError, "failed to record check-in")
return
}
summary, _ := h.repo.Summary(r.Context(), eventID)
h.broadcast(eventID, "check_in.recorded", map[string]any{
"check_in": ci,
"guest_id": ci.GuestID,
"guest_name": guest.Name,
"walk_in": true,
"arrived_headcount": summary.ArrivedHeadcount,
"expected_headcount": summary.ExpectedHeadcount,
"guests_checked_in": summary.GuestsCheckedIn,
})
writeJSON(w, http.StatusCreated, recordCheckInResponse{
CheckIn: *ci,
Guest: guest,
Summary: summary,
})
}
// --- list + summary ---
// GET /events/{id}/check-ins — viewer+.
func (h *checkInHandler) list(w http.ResponseWriter, r *http.Request) {
hostID, ok := hostFromContext(w, r)
if !ok {
return
}
eventID, ok := parseIDParam(w, r, "id")
if !ok {
return
}
if _, _, ok := requireRole(w, r, h.events, h.collabs, eventID, hostID, domain.RoleViewer); !ok {
return
}
rows, err := h.repo.ListByEvent(r.Context(), eventID)
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to list check-ins")
return
}
summary, _ := h.repo.Summary(r.Context(), eventID)
writeJSON(w, http.StatusOK, map[string]any{
"check_ins": rows,
"summary": summary,
})
}
// --- helpers ---
func (h *checkInHandler) broadcast(eventID uuid.UUID, evtType string, payload any) {
if h.hub == nil {
return
}
body, err := json.Marshal(payload)
if err != nil {
h.logger.Warn("marshal check-in ws event", "err", err)
return
}
h.hub.Broadcast(WSEvent{
Type: evtType,
EventID: eventID,
Payload: body,
})
}
func optStr(s string) *string {
t := strings.TrimSpace(s)
if t == "" {
return nil
}
return &t
}
func nameOf(g *domain.Guest) string {
if g == nil {
return ""
}
return g.Name
}
// renderQRPNG converts a JWT-shaped string into a base64-encoded PNG
// data URL the frontend can drop straight into an <img src>. Used by
// the access response so a successful RSVP comes back with the guest's
// scannable code already rendered.
func renderQRPNG(payload string) (string, error) {
png, err := qrcode.Encode(payload, qrcode.Medium, 320)
if err != nil {
return "", err
}
return "data:image/png;base64," + base64.StdEncoding.EncodeToString(png), nil
}