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