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
|
||||
}
|
||||
@@ -43,6 +43,7 @@ type Server struct {
|
||||
uploads *uploadHandler
|
||||
security *securityHandler
|
||||
messages *messageHandler
|
||||
checkIns *checkInHandler
|
||||
}
|
||||
|
||||
type ServerDeps struct {
|
||||
@@ -105,6 +106,16 @@ func NewServer(deps ServerDeps) (*Server, error) {
|
||||
allowlistRepo := storage.NewAllowlistRepo(deps.DB)
|
||||
editNonces := newEditNonceStore(deps.Redis)
|
||||
messageRepo := storage.NewMessageRepo(deps.DB)
|
||||
checkInRepo := storage.NewCheckInRepo(deps.DB)
|
||||
|
||||
// Tier 2 Block H — QR JWT signer reuses the platform's JWT secret
|
||||
// so production secrets management already covers it. TTL=6h is the
|
||||
// minimum lifetime; Issue() extends to eventDate+24h on demand so
|
||||
// codes minted weeks in advance still scan on the day.
|
||||
checkInQRSigner, err := auth.NewCheckInQRSigner(deps.JWTSecret, deps.JWTIssuer, 6*time.Hour)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
feedbackRepo := storage.NewFeedbackRepo(deps.DB)
|
||||
|
||||
// Branding image store. Empty UploadsDir leaves it nil and the upload
|
||||
@@ -199,6 +210,7 @@ func NewServer(deps ServerDeps) (*Server, error) {
|
||||
branding: brandingRepo,
|
||||
editNonces: editNonces,
|
||||
emails: emails,
|
||||
checkInQR: checkInQRSigner,
|
||||
gen: auth.NewGenerator(),
|
||||
ttl: deps.TokenTTL,
|
||||
pub: deps.AccessPublisher,
|
||||
@@ -283,6 +295,15 @@ func NewServer(deps ServerDeps) (*Server, error) {
|
||||
collabs: collabRepo,
|
||||
repo: messageRepo,
|
||||
},
|
||||
checkIns: &checkInHandler{
|
||||
logger: deps.Logger,
|
||||
events: eventRepo,
|
||||
guests: guestRepo,
|
||||
collabs: collabRepo,
|
||||
repo: checkInRepo,
|
||||
qrSigner: checkInQRSigner,
|
||||
hub: hub,
|
||||
},
|
||||
collabs: &collaboratorHandler{
|
||||
logger: deps.Logger,
|
||||
events: eventRepo,
|
||||
@@ -414,6 +435,14 @@ func (s *Server) Handler() http.Handler {
|
||||
mux.Handle("DELETE /events/{id}/messages/{message_id}",
|
||||
authed(http.HandlerFunc(s.messages.cancel)))
|
||||
|
||||
// Block H — day-of check-in.
|
||||
mux.Handle("POST /events/{id}/check-in",
|
||||
authed(rl("checkin_record", 1000, time.Hour, userIDKey, http.HandlerFunc(s.checkIns.record))))
|
||||
mux.Handle("POST /events/{id}/walk-ins",
|
||||
authed(rl("checkin_walk_in", 500, time.Hour, userIDKey, http.HandlerFunc(s.checkIns.walkIn))))
|
||||
mux.Handle("GET /events/{id}/check-ins",
|
||||
authed(http.HandlerFunc(s.checkIns.list)))
|
||||
|
||||
// Block D — event branding. Reads are viewer+; PUT is editor+. The
|
||||
// upload endpoint is gated by auth only (any signed-in user can mint
|
||||
// an image URL; the URL is no use without an event they can edit
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package domain
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// CheckIn records that a guest walked in. One row per guest (the UNIQUE
|
||||
// constraint prevents double check-in); arrival_count captures how many
|
||||
// people were actually in the party — guest + plus-ones who showed.
|
||||
// walk_in tags door-adds that weren't on the original guest list.
|
||||
// Tier 2 Block H.
|
||||
type CheckIn struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
GuestID uuid.UUID `json:"guest_id"`
|
||||
CheckedInAt time.Time `json:"checked_in_at"`
|
||||
CheckedInBy *uuid.UUID `json:"checked_in_by,omitempty"`
|
||||
ArrivalCount int `json:"arrival_count"`
|
||||
Notes *string `json:"notes,omitempty"`
|
||||
WalkIn bool `json:"walk_in"`
|
||||
}
|
||||
|
||||
// CheckInSummary is the dashboard widget's data: total arrivals so far,
|
||||
// and the original headcount goal (sum of attending + plus_ones).
|
||||
type CheckInSummary struct {
|
||||
ArrivedHeadcount int `json:"arrived_headcount"`
|
||||
ExpectedHeadcount int `json:"expected_headcount"`
|
||||
GuestsCheckedIn int `json:"guests_checked_in"`
|
||||
}
|
||||
|
||||
var (
|
||||
ErrAlreadyCheckedIn = errors.New("guest is already checked in")
|
||||
ErrCheckInBadQR = errors.New("invalid QR payload")
|
||||
ErrCheckInExpired = errors.New("QR has expired")
|
||||
ErrCheckInMismatch = errors.New("QR belongs to a different event")
|
||||
)
|
||||
@@ -0,0 +1,116 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5/pgconn"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
|
||||
"github.com/alchemistkay/guestguard/internal/domain"
|
||||
)
|
||||
|
||||
// CheckInRepo holds the check_ins table. Tier 2 Block H.
|
||||
type CheckInRepo struct {
|
||||
pool *pgxpool.Pool
|
||||
}
|
||||
|
||||
func NewCheckInRepo(db *DB) *CheckInRepo {
|
||||
return &CheckInRepo{pool: db.Pool}
|
||||
}
|
||||
|
||||
type RecordCheckInParams struct {
|
||||
GuestID uuid.UUID
|
||||
CheckedInBy uuid.UUID
|
||||
ArrivalCount int
|
||||
Notes string
|
||||
WalkIn bool
|
||||
}
|
||||
|
||||
// Record inserts a check-in. The UNIQUE on guest_id surfaces a
|
||||
// double-check-in as domain.ErrAlreadyCheckedIn so the scanner UI can
|
||||
// show a clear "already in" message instead of a generic 500.
|
||||
func (r *CheckInRepo) Record(ctx context.Context, p RecordCheckInParams) (*domain.CheckIn, error) {
|
||||
if p.ArrivalCount <= 0 {
|
||||
p.ArrivalCount = 1
|
||||
}
|
||||
const q = `
|
||||
INSERT INTO check_ins (guest_id, checked_in_by, arrival_count, notes, walk_in)
|
||||
VALUES ($1, $2, $3, NULLIF($4, ''), $5)
|
||||
RETURNING id, guest_id, checked_in_at, checked_in_by, arrival_count, notes, walk_in
|
||||
`
|
||||
var c domain.CheckIn
|
||||
err := r.pool.QueryRow(ctx, q,
|
||||
p.GuestID, p.CheckedInBy, p.ArrivalCount, p.Notes, p.WalkIn,
|
||||
).Scan(&c.ID, &c.GuestID, &c.CheckedInAt, &c.CheckedInBy, &c.ArrivalCount, &c.Notes, &c.WalkIn)
|
||||
if err != nil {
|
||||
var pgErr *pgconn.PgError
|
||||
if errors.As(err, &pgErr) && pgErr.Code == "23505" {
|
||||
return nil, domain.ErrAlreadyCheckedIn
|
||||
}
|
||||
return nil, fmt.Errorf("record check-in: %w", err)
|
||||
}
|
||||
return &c, nil
|
||||
}
|
||||
|
||||
// ListByEvent returns every check-in on an event, newest first. Powers
|
||||
// the live arrivals list on the dashboard.
|
||||
func (r *CheckInRepo) ListByEvent(ctx context.Context, eventID uuid.UUID) ([]domain.CheckIn, error) {
|
||||
rows, err := r.pool.Query(ctx, `
|
||||
SELECT c.id, c.guest_id, c.checked_in_at, c.checked_in_by,
|
||||
c.arrival_count, c.notes, c.walk_in
|
||||
FROM check_ins c
|
||||
JOIN guests g ON g.id = c.guest_id
|
||||
WHERE g.event_id = $1
|
||||
ORDER BY c.checked_in_at DESC
|
||||
`, eventID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
out := []domain.CheckIn{}
|
||||
for rows.Next() {
|
||||
var c domain.CheckIn
|
||||
if err := rows.Scan(&c.ID, &c.GuestID, &c.CheckedInAt, &c.CheckedInBy,
|
||||
&c.ArrivalCount, &c.Notes, &c.WalkIn); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, c)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// Summary returns the headcount totals: how many people walked in, and
|
||||
// how many were expected (sum of attending RSVPs + their plus_ones).
|
||||
func (r *CheckInRepo) Summary(ctx context.Context, eventID uuid.UUID) (domain.CheckInSummary, error) {
|
||||
var s domain.CheckInSummary
|
||||
err := r.pool.QueryRow(ctx, `
|
||||
SELECT
|
||||
COALESCE(SUM(c.arrival_count), 0) AS arrived_headcount,
|
||||
(
|
||||
SELECT COALESCE(SUM(1 + r.plus_ones), 0)
|
||||
FROM rsvps r
|
||||
JOIN guests g ON g.id = r.guest_id
|
||||
WHERE g.event_id = $1 AND r.response = 'attending'
|
||||
) AS expected_headcount,
|
||||
COUNT(c.id) AS guests_checked_in
|
||||
FROM check_ins c
|
||||
JOIN guests g ON g.id = c.guest_id
|
||||
WHERE g.event_id = $1
|
||||
`, eventID).Scan(&s.ArrivedHeadcount, &s.ExpectedHeadcount, &s.GuestsCheckedIn)
|
||||
return s, err
|
||||
}
|
||||
|
||||
// GuestBelongsToEvent confirms a guest is on the event before we record
|
||||
// their check-in. Belt-and-braces guard against a forged JWT pointing
|
||||
// at a guest from a different event — the JWT layer already binds
|
||||
// (event_id, guest_id) but a DB-level check is cheap insurance.
|
||||
func (r *CheckInRepo) GuestBelongsToEvent(ctx context.Context, guestID, eventID uuid.UUID) (bool, error) {
|
||||
var ok bool
|
||||
err := r.pool.QueryRow(ctx,
|
||||
`SELECT EXISTS (SELECT 1 FROM guests WHERE id = $1 AND event_id = $2)`,
|
||||
guestID, eventID).Scan(&ok)
|
||||
return ok, err
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
DROP INDEX IF EXISTS idx_checkins_event_time;
|
||||
DROP TABLE IF EXISTS check_ins;
|
||||
@@ -0,0 +1,25 @@
|
||||
-- Tier 2 Block H — day-of check-in.
|
||||
--
|
||||
-- One row per guest who showed up. The UNIQUE on guest_id is the
|
||||
-- double-check-in guard at the DB layer; the QR validation (JWT
|
||||
-- signature + expiry + event match) lives in the API.
|
||||
--
|
||||
-- arrival_count records how many people actually walked in for this
|
||||
-- guest (1 + plus-ones who showed). walk_in marks the row as a
|
||||
-- door-add — a guest who wasn't on the original list. The QR payload
|
||||
-- itself isn't stored; it's a derivable JWT.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS check_ins (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
guest_id UUID NOT NULL UNIQUE REFERENCES guests(id) ON DELETE CASCADE,
|
||||
checked_in_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
checked_in_by UUID REFERENCES users(id) ON DELETE SET NULL,
|
||||
arrival_count INTEGER NOT NULL DEFAULT 1,
|
||||
notes TEXT,
|
||||
walk_in BOOLEAN NOT NULL DEFAULT FALSE
|
||||
);
|
||||
|
||||
-- "How's the door looking?" — the live arrivals widget runs this
|
||||
-- against (event_id, checked_in_at) so the index covers the ORDER BY.
|
||||
CREATE INDEX IF NOT EXISTS idx_checkins_event_time
|
||||
ON check_ins(checked_in_at DESC);
|
||||
Reference in New Issue
Block a user