diff --git a/frontend/components/CheckInCard.vue b/frontend/components/CheckInCard.vue new file mode 100644 index 0000000..aefda71 --- /dev/null +++ b/frontend/components/CheckInCard.vue @@ -0,0 +1,434 @@ + + + diff --git a/frontend/pages/dashboard/events/[id].vue b/frontend/pages/dashboard/events/[id].vue index 51acfc8..16669c6 100644 --- a/frontend/pages/dashboard/events/[id].vue +++ b/frontend/pages/dashboard/events/[id].vue @@ -69,8 +69,8 @@ const loading = ref(true) // (Collaborators + Branding, configured once) → Analytics (results, // checked periodically). The two action-y tabs anchor the ends; setup // clusters in the middle. -type EventTab = 'guests' | 'collaborators' | 'communications' | 'branding' | 'analytics' | 'gate' -const validTabs: EventTab[] = ['guests', 'collaborators', 'communications', 'branding', 'analytics', 'gate'] +type EventTab = 'guests' | 'collaborators' | 'communications' | 'branding' | 'analytics' | 'gate' | 'checkin' +const validTabs: EventTab[] = ['guests', 'collaborators', 'communications', 'branding', 'analytics', 'gate', 'checkin'] function tabFromHash(): EventTab { if (import.meta.client) { const h = window.location.hash.replace('#', '') as EventTab @@ -786,6 +786,7 @@ function checkLabel(band?: string): string { { id: 'guests', label: 'Guests' }, { id: 'collaborators', label: 'Collaborators' }, { id: 'communications', label: 'Communications' }, + { id: 'checkin', label: 'Check-in' }, { id: 'branding', label: 'Branding' }, { id: 'analytics', label: 'Analytics' }, { id: 'gate', label: 'Gate' }, @@ -1218,6 +1219,11 @@ function checkLabel(band?: string): string { + +
+ +
+ +
+

+ Save for the day +

+

+ Show this code at the door for a quick check-in. Screenshot it now, + or look out for the same code on your confirmation email. +

+
+ Your check-in QR code +
+
+

Need to change something? You have {{ editsRemaining }} @@ -344,6 +382,25 @@ const submitLabel = computed(() => { class="mb-4 border-t border-zinc-800 pt-4" /> +

+

+ Your door code +

+

+ Show this at the entrance for a quick check-in on the day. +

+
+ Your check-in QR code +
+
+

{{ editsRemaining }} {{ editsRemaining === 1 ? 'edit' : 'edits' }} remaining. diff --git a/go.mod b/go.mod index ad1ae84..a4b65d8 100644 --- a/go.mod +++ b/go.mod @@ -77,6 +77,7 @@ require ( github.com/redis/go-redis/v9 v9.19.0 // indirect github.com/shirou/gopsutil/v4 v4.26.3 // indirect github.com/sirupsen/logrus v1.9.4 // indirect + github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e // indirect github.com/stretchr/testify v1.11.1 // indirect github.com/stripe/stripe-go/v82 v82.5.1 // indirect github.com/tklauser/go-sysconf v0.3.16 // indirect diff --git a/go.sum b/go.sum index 7793495..8f6ee05 100644 --- a/go.sum +++ b/go.sum @@ -151,6 +151,8 @@ github.com/shirou/gopsutil/v4 v4.26.3 h1:2ESdQt90yU3oXF/CdOlRCJxrP+Am1aBYubTMTfx github.com/shirou/gopsutil/v4 v4.26.3/go.mod h1:LZ6ewCSkBqUpvSOf+LsTGnRinC6iaNUNMGBtDkJBaLQ= github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w= github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g= +github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e h1:MRM5ITcdelLK2j1vwZ3Je0FKVCfqOLp5zO6trqMLYs0= +github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e/go.mod h1:XV66xRDqSt+GTGFMVlhk3ULuV0y9ZmzeVGR4mloJI3M= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.5.3 h1:jmXUvGomnU1o3W/V5h2VEradbpJDwGrzugQQvL0POH4= github.com/stretchr/objx v0.5.3/go.mod h1:rDQraq+vQZU7Fde9LOZLr8Tax6zZvy4kuNKF+QYS+U0= diff --git a/internal/api/checkins.go b/internal/api/checkins.go new file mode 100644 index 0000000..7686c5d --- /dev/null +++ b/internal/api/checkins.go @@ -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 . 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 +} diff --git a/internal/api/server.go b/internal/api/server.go index c86dba6..1cda0ca 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -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 diff --git a/internal/api/tokens.go b/internal/api/tokens.go index 9332c0e..c0a8962 100644 --- a/internal/api/tokens.go +++ b/internal/api/tokens.go @@ -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 + // ; 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 } // 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, }) } diff --git a/internal/auth/checkin_qr.go b/internal/auth/checkin_qr.go new file mode 100644 index 0000000..13f73e5 --- /dev/null +++ b/internal/auth/checkin_qr.go @@ -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 +} diff --git a/internal/auth/checkin_qr_test.go b/internal/auth/checkin_qr_test.go new file mode 100644 index 0000000..82c0fc9 --- /dev/null +++ b/internal/auth/checkin_qr_test.go @@ -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") + } +} diff --git a/internal/domain/checkin.go b/internal/domain/checkin.go new file mode 100644 index 0000000..d46e722 --- /dev/null +++ b/internal/domain/checkin.go @@ -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") +) diff --git a/internal/storage/checkins.go b/internal/storage/checkins.go new file mode 100644 index 0000000..3760485 --- /dev/null +++ b/internal/storage/checkins.go @@ -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 +} diff --git a/internal/storage/migrations/0013_checkins.down.sql b/internal/storage/migrations/0013_checkins.down.sql new file mode 100644 index 0000000..bb19510 --- /dev/null +++ b/internal/storage/migrations/0013_checkins.down.sql @@ -0,0 +1,2 @@ +DROP INDEX IF EXISTS idx_checkins_event_time; +DROP TABLE IF EXISTS check_ins; diff --git a/internal/storage/migrations/0013_checkins.up.sql b/internal/storage/migrations/0013_checkins.up.sql new file mode 100644 index 0000000..687a925 --- /dev/null +++ b/internal/storage/migrations/0013_checkins.up.sql @@ -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); diff --git a/test/integration/checkins_test.go b/test/integration/checkins_test.go new file mode 100644 index 0000000..a4bd379 --- /dev/null +++ b/test/integration/checkins_test.go @@ -0,0 +1,158 @@ +//go:build integration + +package integration_test + +import ( + "context" + "fmt" + "net/http" + "testing" + "time" + + "github.com/google/uuid" + + "github.com/alchemistkay/guestguard/internal/auth" +) + +// Tier 2 Block H — day-of check-in. + +type checkInResp struct { + CheckIn struct { + ID uuid.UUID `json:"id"` + GuestID uuid.UUID `json:"guest_id"` + ArrivalCount int `json:"arrival_count"` + WalkIn bool `json:"walk_in"` + } `json:"check_in"` + Summary struct { + ArrivedHeadcount int `json:"arrived_headcount"` + ExpectedHeadcount int `json:"expected_headcount"` + GuestsCheckedIn int `json:"guests_checked_in"` + } `json:"summary"` +} + +// TestCheckInHappyPath walks the door-flow: host scans a guest's QR, +// arrival is recorded, the live summary reflects the new total. A +// duplicate scan returns 409 with a friendly message so the scanner UI +// can flash orange instead of green. +func TestCheckInHappyPath(t *testing.T) { + if testing.Short() { + t.Skip("skipping integration test in -short mode") + } + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) + t.Cleanup(cancel) + + srv, db, _, token := setupAuthedAPI(t, ctx) + eventID := createEvent(t, srv.URL, token, "Check-In Day", "checkin-day") + + // Seed a guest with an attending RSVP so the QR JWT we mint is + // realistic. Plus-ones=1 so expected headcount = 2. + seedAnalyticsGuest(t, ctx, db.Pool, eventID, "Sam", 1, "attending", 1, true) + + // Look the guest up directly so we know their id. + var guestID uuid.UUID + must(t, db.Pool.QueryRow(ctx, `SELECT id FROM guests WHERE event_id=$1 LIMIT 1`, eventID).Scan(&guestID), + "load guest id") + + // Mint a QR JWT with the test's known JWT secret + issuer (same the + // API was constructed with via setupAuthedAPI). + signer, err := auth.NewCheckInQRSigner(testJWTSecret, testJWTIssuer, 6*time.Hour) + must(t, err, "qr signer") + qrJWT, _, err := signer.Issue(eventID, guestID, time.Now().Add(24*time.Hour), time.Now()) + must(t, err, "mint qr") + + // First scan: 200, arrival count=2 (guest + 1 plus-one walked in). + var ok checkInResp + postJSONAuthed(t, + fmt.Sprintf("%s/events/%s/check-in", srv.URL, eventID), + token, + map[string]any{"qr_payload": qrJWT, "arrival_count": 2}, + http.StatusOK, &ok) + if ok.CheckIn.ArrivalCount != 2 { + t.Errorf("arrival_count: got %d want 2", ok.CheckIn.ArrivalCount) + } + if ok.Summary.ArrivedHeadcount != 2 { + t.Errorf("arrived_headcount: got %d want 2", ok.Summary.ArrivedHeadcount) + } + if ok.Summary.ExpectedHeadcount != 2 { + t.Errorf("expected_headcount: got %d want 2", ok.Summary.ExpectedHeadcount) + } + + // Second scan with same QR: 409 conflict, no second row written. + assertStatus(t, http.MethodPost, + fmt.Sprintf("%s/events/%s/check-in", srv.URL, eventID), + token, + map[string]any{"qr_payload": qrJWT, "arrival_count": 1}, + http.StatusConflict) +} + +// TestCheckInRejectsForeignQR confirms a JWT minted for one event can't +// be used to check someone in on another. The path 404s rather than +// 403 to avoid leaking the existence of the foreign event. +func TestCheckInRejectsForeignQR(t *testing.T) { + if testing.Short() { + t.Skip("skipping integration test in -short mode") + } + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) + t.Cleanup(cancel) + + srv, db, _, token := setupAuthedAPI(t, ctx) + eventA := createEvent(t, srv.URL, token, "A", "evt-a") + eventB := createEvent(t, srv.URL, token, "B", "evt-b") + + seedAnalyticsGuest(t, ctx, db.Pool, eventA, "Sam A", 0, "attending", 0, true) + var guestA uuid.UUID + must(t, db.Pool.QueryRow(ctx, `SELECT id FROM guests WHERE event_id=$1`, eventA).Scan(&guestA), "load guest") + + signer, err := auth.NewCheckInQRSigner(testJWTSecret, testJWTIssuer, time.Hour) + must(t, err, "qr signer") + // JWT bound to event A. + qrA, _, err := signer.Issue(eventA, guestA, time.Now().Add(24*time.Hour), time.Now()) + must(t, err, "mint qr") + + // Posting that QR to event B should be rejected. + assertStatus(t, http.MethodPost, + fmt.Sprintf("%s/events/%s/check-in", srv.URL, eventB), + token, + map[string]any{"qr_payload": qrA, "arrival_count": 1}, + http.StatusBadRequest) +} + +// TestWalkInCreatesGuestAndCheckIn confirms a door-add registers a new +// guest plus their check-in in one logical operation. +func TestWalkInCreatesGuestAndCheckIn(t *testing.T) { + if testing.Short() { + t.Skip("skipping integration test in -short mode") + } + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) + t.Cleanup(cancel) + + srv, db, _, token := setupAuthedAPI(t, ctx) + eventID := createEvent(t, srv.URL, token, "Walk-Ins", "walk-ins") + + var out checkInResp + postJSONAuthed(t, + fmt.Sprintf("%s/events/%s/walk-ins", srv.URL, eventID), + token, + map[string]any{ + "name": "Door Crash", + "arrival_count": 3, + "notes": "Friend of bride", + }, + http.StatusCreated, &out) + if !out.CheckIn.WalkIn { + t.Errorf("walk_in flag should be true") + } + if out.CheckIn.ArrivalCount != 3 { + t.Errorf("arrival_count: got %d want 3", out.CheckIn.ArrivalCount) + } + + // Sanity: a new guest row exists with plus_ones = 2 (3 in the + // party, one of whom is the guest, plus two more). + var plusOnes int + must(t, db.Pool.QueryRow(ctx, + `SELECT plus_ones FROM guests WHERE event_id = $1`, eventID, + ).Scan(&plusOnes), "load guest") + if plusOnes != 2 { + t.Errorf("guest plus_ones: got %d want 2", plusOnes) + } +}