feat(tier2): day-of check-in — Block H
QR codes on RSVP confirmations, a phone-friendly door scanner, walk-in
support, and a live arrivals widget that updates over WebSocket. Closes
the final Tier 2 block.
Schema (migration 0013)
- check_ins (id, guest_id UNIQUE, checked_in_at, checked_in_by,
arrival_count, notes, walk_in). UNIQUE on guest_id is the
double-check-in guard at the DB layer; signature validation lives
in the QR JWT.
QR JWT
- internal/auth/checkin_qr.go: CheckInQRSigner mints {event_id,
guest_id, exp} payloads with the platform's existing HMAC secret.
Issue() extends expiry to eventDate+24h so a QR minted weeks in
advance still scans on the day. Parse() distinguishes
ErrExpiredJWT from generic ErrInvalidJWT so the API can render a
friendlier 410.
- Unit tests cover round-trip, wrong-secret rejection, expiry
detection, and short-secret refusal at construction time.
Domain + storage
- domain.CheckIn + CheckInSummary
- storage.CheckInRepo: Record (returns ErrAlreadyCheckedIn on the
unique violation), ListByEvent, Summary (arrived headcount,
expected headcount, guests-checked-in count), GuestBelongsToEvent
(belt-and-braces guard against a forged JWT pointing at a
different event's guest).
API
- GET /access/{token} now embeds a check_in payload (raw JWT + a
base64-encoded PNG via skip2/go-qrcode) for attending RSVPs, so
the confirmation page can render the code straight into an <img>.
- POST /events/{id}/check-in — editor+. Validates the QR JWT,
refuses cross-event payloads (400), refuses expired ones (410),
records the row, broadcasts check_in.recorded over the existing
WS hub so the live dashboard updates.
- POST /events/{id}/walk-ins — editor+. Creates the guest + check-in
in one logical op for a door-add who wasn't on the original list.
- GET /events/{id}/check-ins — viewer+. Returns the list and the
summary together so the dashboard widget hydrates in one call.
Frontend
- New CheckInCard.vue: live arrivals widget ("47 of 60 · 78%" plus
a progress bar), recent-arrivals list, Walk-in button, and a
"Start scanning" button that opens a full-screen camera modal.
jsQR loaded from CDN on first open (no bundler dep). Scan
throttling + dedupe prevents the 30fps camera loop from POSTing
N times per paper QR. Successful scan vibrates the phone.
Duplicate (409) → "Already checked in" toast; expired (410) →
"This code has expired"; foreign-event (400) → "doesn't look
like one of your guests".
- New "Check-in" tab on the event-detail page, between
Communications and Branding.
- RSVP confirmation card + revisit card both surface a "Save for
the day" / "Your door code" QR block for attending guests. The
PNG ships pre-rendered from the API so the frontend doesn't need
its own QR library.
- The submit flow now refetches /access after a successful POST so
the QR appears immediately on first submit, not just on revisit.
Tests
- Backend unit tests for the QR signer (round-trip, wrong-secret,
expired, short-secret rejection).
- Integration: TestCheckInHappyPath (scan -> 200, double-scan ->
409, summary reflects arrival), TestCheckInRejectsForeignQR
(event A's JWT can't be used on event B), TestWalkInCreatesGuest
AndCheckIn (door-add creates both rows).
- Full integration suite passes (188.3s, 41 tests / 80+ subtests).
Tier 2 is complete: Blocks A through H all shipped.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,116 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5/pgconn"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
|
||||
"github.com/alchemistkay/guestguard/internal/domain"
|
||||
)
|
||||
|
||||
// CheckInRepo holds the check_ins table. Tier 2 Block H.
|
||||
type CheckInRepo struct {
|
||||
pool *pgxpool.Pool
|
||||
}
|
||||
|
||||
func NewCheckInRepo(db *DB) *CheckInRepo {
|
||||
return &CheckInRepo{pool: db.Pool}
|
||||
}
|
||||
|
||||
type RecordCheckInParams struct {
|
||||
GuestID uuid.UUID
|
||||
CheckedInBy uuid.UUID
|
||||
ArrivalCount int
|
||||
Notes string
|
||||
WalkIn bool
|
||||
}
|
||||
|
||||
// Record inserts a check-in. The UNIQUE on guest_id surfaces a
|
||||
// double-check-in as domain.ErrAlreadyCheckedIn so the scanner UI can
|
||||
// show a clear "already in" message instead of a generic 500.
|
||||
func (r *CheckInRepo) Record(ctx context.Context, p RecordCheckInParams) (*domain.CheckIn, error) {
|
||||
if p.ArrivalCount <= 0 {
|
||||
p.ArrivalCount = 1
|
||||
}
|
||||
const q = `
|
||||
INSERT INTO check_ins (guest_id, checked_in_by, arrival_count, notes, walk_in)
|
||||
VALUES ($1, $2, $3, NULLIF($4, ''), $5)
|
||||
RETURNING id, guest_id, checked_in_at, checked_in_by, arrival_count, notes, walk_in
|
||||
`
|
||||
var c domain.CheckIn
|
||||
err := r.pool.QueryRow(ctx, q,
|
||||
p.GuestID, p.CheckedInBy, p.ArrivalCount, p.Notes, p.WalkIn,
|
||||
).Scan(&c.ID, &c.GuestID, &c.CheckedInAt, &c.CheckedInBy, &c.ArrivalCount, &c.Notes, &c.WalkIn)
|
||||
if err != nil {
|
||||
var pgErr *pgconn.PgError
|
||||
if errors.As(err, &pgErr) && pgErr.Code == "23505" {
|
||||
return nil, domain.ErrAlreadyCheckedIn
|
||||
}
|
||||
return nil, fmt.Errorf("record check-in: %w", err)
|
||||
}
|
||||
return &c, nil
|
||||
}
|
||||
|
||||
// ListByEvent returns every check-in on an event, newest first. Powers
|
||||
// the live arrivals list on the dashboard.
|
||||
func (r *CheckInRepo) ListByEvent(ctx context.Context, eventID uuid.UUID) ([]domain.CheckIn, error) {
|
||||
rows, err := r.pool.Query(ctx, `
|
||||
SELECT c.id, c.guest_id, c.checked_in_at, c.checked_in_by,
|
||||
c.arrival_count, c.notes, c.walk_in
|
||||
FROM check_ins c
|
||||
JOIN guests g ON g.id = c.guest_id
|
||||
WHERE g.event_id = $1
|
||||
ORDER BY c.checked_in_at DESC
|
||||
`, eventID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
out := []domain.CheckIn{}
|
||||
for rows.Next() {
|
||||
var c domain.CheckIn
|
||||
if err := rows.Scan(&c.ID, &c.GuestID, &c.CheckedInAt, &c.CheckedInBy,
|
||||
&c.ArrivalCount, &c.Notes, &c.WalkIn); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, c)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// Summary returns the headcount totals: how many people walked in, and
|
||||
// how many were expected (sum of attending RSVPs + their plus_ones).
|
||||
func (r *CheckInRepo) Summary(ctx context.Context, eventID uuid.UUID) (domain.CheckInSummary, error) {
|
||||
var s domain.CheckInSummary
|
||||
err := r.pool.QueryRow(ctx, `
|
||||
SELECT
|
||||
COALESCE(SUM(c.arrival_count), 0) AS arrived_headcount,
|
||||
(
|
||||
SELECT COALESCE(SUM(1 + r.plus_ones), 0)
|
||||
FROM rsvps r
|
||||
JOIN guests g ON g.id = r.guest_id
|
||||
WHERE g.event_id = $1 AND r.response = 'attending'
|
||||
) AS expected_headcount,
|
||||
COUNT(c.id) AS guests_checked_in
|
||||
FROM check_ins c
|
||||
JOIN guests g ON g.id = c.guest_id
|
||||
WHERE g.event_id = $1
|
||||
`, eventID).Scan(&s.ArrivedHeadcount, &s.ExpectedHeadcount, &s.GuestsCheckedIn)
|
||||
return s, err
|
||||
}
|
||||
|
||||
// GuestBelongsToEvent confirms a guest is on the event before we record
|
||||
// their check-in. Belt-and-braces guard against a forged JWT pointing
|
||||
// at a guest from a different event — the JWT layer already binds
|
||||
// (event_id, guest_id) but a DB-level check is cheap insurance.
|
||||
func (r *CheckInRepo) GuestBelongsToEvent(ctx context.Context, guestID, eventID uuid.UUID) (bool, error) {
|
||||
var ok bool
|
||||
err := r.pool.QueryRow(ctx,
|
||||
`SELECT EXISTS (SELECT 1 FROM guests WHERE id = $1 AND event_id = $2)`,
|
||||
guestID, eventID).Scan(&ok)
|
||||
return ok, err
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
DROP INDEX IF EXISTS idx_checkins_event_time;
|
||||
DROP TABLE IF EXISTS check_ins;
|
||||
@@ -0,0 +1,25 @@
|
||||
-- Tier 2 Block H — day-of check-in.
|
||||
--
|
||||
-- One row per guest who showed up. The UNIQUE on guest_id is the
|
||||
-- double-check-in guard at the DB layer; the QR validation (JWT
|
||||
-- signature + expiry + event match) lives in the API.
|
||||
--
|
||||
-- arrival_count records how many people actually walked in for this
|
||||
-- guest (1 + plus-ones who showed). walk_in marks the row as a
|
||||
-- door-add — a guest who wasn't on the original list. The QR payload
|
||||
-- itself isn't stored; it's a derivable JWT.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS check_ins (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
guest_id UUID NOT NULL UNIQUE REFERENCES guests(id) ON DELETE CASCADE,
|
||||
checked_in_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
checked_in_by UUID REFERENCES users(id) ON DELETE SET NULL,
|
||||
arrival_count INTEGER NOT NULL DEFAULT 1,
|
||||
notes TEXT,
|
||||
walk_in BOOLEAN NOT NULL DEFAULT FALSE
|
||||
);
|
||||
|
||||
-- "How's the door looking?" — the live arrivals widget runs this
|
||||
-- against (event_id, checked_in_at) so the index covers the ORDER BY.
|
||||
CREATE INDEX IF NOT EXISTS idx_checkins_event_time
|
||||
ON check_ins(checked_in_at DESC);
|
||||
Reference in New Issue
Block a user