feat(tier2): day-of check-in — Block H

QR codes on RSVP confirmations, a phone-friendly door scanner, walk-in
support, and a live arrivals widget that updates over WebSocket. Closes
the final Tier 2 block.

Schema (migration 0013)
- check_ins (id, guest_id UNIQUE, checked_in_at, checked_in_by,
  arrival_count, notes, walk_in). UNIQUE on guest_id is the
  double-check-in guard at the DB layer; signature validation lives
  in the QR JWT.

QR JWT
- internal/auth/checkin_qr.go: CheckInQRSigner mints {event_id,
  guest_id, exp} payloads with the platform's existing HMAC secret.
  Issue() extends expiry to eventDate+24h so a QR minted weeks in
  advance still scans on the day. Parse() distinguishes
  ErrExpiredJWT from generic ErrInvalidJWT so the API can render a
  friendlier 410.
- Unit tests cover round-trip, wrong-secret rejection, expiry
  detection, and short-secret refusal at construction time.

Domain + storage
- domain.CheckIn + CheckInSummary
- storage.CheckInRepo: Record (returns ErrAlreadyCheckedIn on the
  unique violation), ListByEvent, Summary (arrived headcount,
  expected headcount, guests-checked-in count), GuestBelongsToEvent
  (belt-and-braces guard against a forged JWT pointing at a
  different event's guest).

API
- GET /access/{token} now embeds a check_in payload (raw JWT + a
  base64-encoded PNG via skip2/go-qrcode) for attending RSVPs, so
  the confirmation page can render the code straight into an <img>.
- POST /events/{id}/check-in — editor+. Validates the QR JWT,
  refuses cross-event payloads (400), refuses expired ones (410),
  records the row, broadcasts check_in.recorded over the existing
  WS hub so the live dashboard updates.
- POST /events/{id}/walk-ins — editor+. Creates the guest + check-in
  in one logical op for a door-add who wasn't on the original list.
- GET /events/{id}/check-ins — viewer+. Returns the list and the
  summary together so the dashboard widget hydrates in one call.

Frontend
- New CheckInCard.vue: live arrivals widget ("47 of 60 · 78%" plus
  a progress bar), recent-arrivals list, Walk-in button, and a
  "Start scanning" button that opens a full-screen camera modal.
  jsQR loaded from CDN on first open (no bundler dep). Scan
  throttling + dedupe prevents the 30fps camera loop from POSTing
  N times per paper QR. Successful scan vibrates the phone.
  Duplicate (409) → "Already checked in" toast; expired (410) →
  "This code has expired"; foreign-event (400) → "doesn't look
  like one of your guests".
- New "Check-in" tab on the event-detail page, between
  Communications and Branding.
- RSVP confirmation card + revisit card both surface a "Save for
  the day" / "Your door code" QR block for attending guests. The
  PNG ships pre-rendered from the API so the frontend doesn't need
  its own QR library.
- The submit flow now refetches /access after a successful POST so
  the QR appears immediately on first submit, not just on revisit.

Tests
- Backend unit tests for the QR signer (round-trip, wrong-secret,
  expired, short-secret rejection).
- Integration: TestCheckInHappyPath (scan -> 200, double-scan ->
  409, summary reflects arrival), TestCheckInRejectsForeignQR
  (event A's JWT can't be used on event B), TestWalkInCreatesGuest
  AndCheckIn (door-add creates both rows).
- Full integration suite passes (188.3s, 41 tests / 80+ subtests).

Tier 2 is complete: Blocks A through H all shipped.

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