Door scanner and live arrivals.
+{{ error }}
+Loading…
+ +Arrived
++ {{ summary.arrived_headcount }} + of {{ summary.expected_headcount }} +
++ {{ summary.guests_checked_in }} + {{ summary.guests_checked_in === 1 ? 'guest' : 'guests' }} checked in +
+{{ arrivalPct }}%
+of expected
+Recent arrivals
+No one's checked in yet.
++ Guest checked in + walk-in +
++ {{ fmtTime(c.checked_in_at) }} · party of {{ c.arrival_count }} +
+Scanning…
+ ++ Adds them to your guest list and marks them checked in. +
+ ++ 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. +
+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. +
+
{{ 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)
+ }
+}