- docs/TIER2_PLAN.md: detailed sequencing for the 8 blocks of Tier 2 work (editable RSVPs, calendar integration, multi-host, branding, analytics, smarter fraud, reminders + broadcasts, day-of check-in) with schema changes, endpoints, tests, and effort estimates per block. - CLAUDE.md: roadmap section now points at both TIER1_PLAN.md and TIER2_PLAN.md. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
34 KiB
Tier 2 Production Plan
The next six months after launch. Features paying customers will ask for almost immediately — and that, if missing, will lose you the renewal.
Read
CLAUDE.mdfor project conventions anddocs/TIER1_PLAN.mdfor the launch-blocker work that must land before any block in this document. Tier 1 changes the auth model and notification infrastructure that several Tier 2 blocks depend on; do not start in parallel.
TL;DR
Eight work blocks (A–H), grouped into four waves that respect dependencies. Estimated effort: ~10–12 weeks for one engineer, ~6–7 weeks for two.
Wave 1 (quick wins, no model changes):
A. Editable RSVPs ──┐
├── (independent)
B. Calendar integration ──┘
Wave 2 (authorisation surface widens):
C. Multi-host / collaborators
Wave 3 (depends on Wave 2's role model):
D. Event branding ──┐
E. Host analytics ──┼── (parallelisable)
G. Smarter fraud ───┘
Wave 4 (depends on Tier 1 notifications + Wave 3 stability):
F. Reminders + broadcasts ──┐
├── (parallelisable)
H. Day-of check-in ─────────┘
Block A — Editable RSVPs
Why first: high-impact for guest UX, no schema-wide changes, no dependency on other blocks. Real wedding scenario: aunty said "attending" Monday and "can't make it" Friday — today she has no way to fix that.
Goal
A guest revisits their personal link after submitting and can change their response or plus-one count. History of changes is preserved so hosts aren't surprised by a silent overwrite.
Schema changes
Migration 0004_rsvp_edits.up.sql:
-- Track every prior state of an RSVP so hosts can see the trail.
CREATE TABLE rsvp_revisions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
rsvp_id UUID NOT NULL REFERENCES rsvps(id) ON DELETE CASCADE,
prev_response rsvp_response NOT NULL,
prev_plus_ones INT NOT NULL,
prev_dietary TEXT,
changed_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX idx_rsvp_revisions_rsvp ON rsvp_revisions(rsvp_id, changed_at DESC);
-- Allow up to 5 edits per guest; the limiter sits in Redis (Tier 1 Block C).
ALTER TABLE rsvps ADD COLUMN edit_count SMALLINT NOT NULL DEFAULT 0;
Backend
PATCH /rsvp/{token}— new endpoint- Validates token (same as POST)
- Re-runs the fraud check (different device on the edit attempt is itself a signal)
- Snapshots current state into
rsvp_revisionsbefore applying - Rejects if
edit_count >= 5with HTTP 429 - Increments
edit_count
GET /events/{id}/guests/{guest_id}/rsvp/history— host-only, returns current state plus all revisions- Tokens stay valid after the first submission (today they get marked
usedinMarkUsed— remove that call from the submit handler)
Frontend
/rsvp/{token}page detects existing RSVP from the access response- Shows the current submission with a "Change my response" button
- Same form, prefilled with current values, hits PATCH on submit
- Confirmation page after edit: "Your response has been updated"
- Event detail page (host view): per-guest "Show history" toggle reveals the revision trail with timestamps
Tests
- Integration: submit → re-submit changes response and plus_ones → history table has one revision
- Integration: 6th edit attempt returns 429
- Integration: token used for editing isn't burned (can edit again next day)
- Unit: revision snapshot captures the previous state, not the new one
Definition of done
- Migration
0004_rsvp_edits.up.sqlapplied (with reversible down) - Guests can edit their RSVP and see confirmation
- Hosts see edit history in the guest list
- Edit limit enforced (5)
- Existing single-submit flow still works for first-time RSVPs
Effort: ~3–4 days.
Block B — Calendar integration
Why first: trivial to build, instantly delights guests, demos extremely well to potential customers in sales calls. No dependencies, parallel with Block A.
Goal
After confirming attendance, guests can add the event to Google, Outlook, or Apple Calendar with one click.
Schema changes
None.
Backend
GET /access/{token}/calendar.ics— returns a valid iCalendar file- Properties:
SUMMARY(event name),DTSTART/DTEND(event date plus default 2-hour duration if no end specified),LOCATION(venue),DESCRIPTION(host's optional event description),UID(deterministic per event so re-downloads update the same calendar entry) Content-Disposition: attachment; filename="event-name.ics"
- Properties:
- Helper in
internal/calendar/that builds the three add-to-provider URLs:- Google:
https://www.google.com/calendar/render?action=TEMPLATE&text=...&dates=...&details=...&location=... - Outlook:
https://outlook.live.com/calendar/0/deeplink/compose?... - Yahoo (bonus):
https://calendar.yahoo.com/?v=60&title=...
- Google:
- Embedded in the
/access/{token}response so the frontend doesn't have to reconstruct them
Frontend
- Confirmation page (after successful RSVP): "Add to calendar" section with
four buttons:
- Google Calendar (opens link in new tab)
- Outlook (opens link in new tab)
- Apple Calendar (triggers
.icsdownload) - "Other calendar app" (also
.icsdownload)
- Same buttons on the RSVP page if the guest already responded
Tests
- Unit:
.icsoutput is valid (parse it withgithub.com/arran4/golang-icalin the test or just regex the required fields) - Unit: timezones encoded correctly (use
TZID=UTCfor simplicity, or derive from the host's locale — see open questions) - Manual: import the generated
.icsinto Apple Calendar and Outlook, confirm it renders right
Definition of done
.icsvalidates against RFC 5545 (no spec violations)- All three provider links open with the right pre-filled event
- Test imports work in iOS Calendar, macOS Calendar, Outlook (web), Google Calendar
- Frontend buttons styled consistently with the rest of the confirmation page
Effort: ~1–2 days.
Block C — Multi-host / collaborators
Why before Wave 3: every later block (branding, analytics, fraud tuning, communications) needs to respect the role model. Build it once here, reference it everywhere after.
Goal
An event can have multiple hosts, each with one of three roles:
- Owner: full access, can delete the event, can manage collaborators
- Editor: can add/edit/remove guests, change event details, send messages, view analytics
- Viewer: read-only access to everything
Inviting a collaborator who doesn't yet have a GuestGuard account triggers a signup flow that lands them on the event after they verify.
Schema changes
Migration 0005_collaborators.up.sql:
CREATE TYPE collaborator_role AS ENUM ('owner', 'editor', 'viewer');
CREATE TABLE event_collaborators (
event_id UUID NOT NULL REFERENCES events(id) ON DELETE CASCADE,
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
role collaborator_role NOT NULL,
invited_by UUID REFERENCES users(id),
invited_at TIMESTAMPTZ NOT NULL DEFAULT now(),
accepted_at TIMESTAMPTZ,
PRIMARY KEY (event_id, user_id)
);
CREATE INDEX idx_collaborators_user ON event_collaborators(user_id);
-- Pending invitations (invitee doesn't have an account yet, or hasn't accepted)
CREATE TABLE collaborator_invites (
token_hash TEXT PRIMARY KEY,
event_id UUID NOT NULL REFERENCES events(id) ON DELETE CASCADE,
email CITEXT NOT NULL,
role collaborator_role NOT NULL,
invited_by UUID NOT NULL REFERENCES users(id),
expires_at TIMESTAMPTZ NOT NULL,
consumed_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX idx_collab_invites_event ON collaborator_invites(event_id) WHERE consumed_at IS NULL;
-- Backfill existing events: the current host_id becomes the owner.
INSERT INTO event_collaborators (event_id, user_id, role, accepted_at)
SELECT id, host_id, 'owner', created_at FROM events
ON CONFLICT DO NOTHING;
Decision: keep events.host_id as a denormalised "primary owner" pointer for
query convenience, but treat event_collaborators as the source of truth for
authz.
Backend
- New repo
storage.CollaboratorRepowith:Add,List,UpdateRole,Remove,RoleFor(eventID, userID) - New repo
storage.InviteRepofor the invitation tokens - Endpoints:
POST /events/{id}/collaborators— body{ email, role }; creates an invitation; emits email via Tier 1'sEmailSenderGET /events/{id}/collaborators— list current + pendingPATCH /events/{id}/collaborators/{user_id}— change role (owner-only)DELETE /events/{id}/collaborators/{user_id}— remove (owner-only; refuses to remove the last owner)POST /invites/{token}/accept— accept invitation; auto-creates user if none exists, redirects to signup if they need to set a password
- Update authz middleware: replace every
WHERE host_id = userIDcheck with a join throughevent_collaborators. Add a helper:requireRole(eventID, userID, minRole)returns 404 if no membership, 403 if role is insufficient- Owner > Editor > Viewer (numeric internally for comparisons)
- Tighten existing endpoints:
PATCH/DELETE /events/{id}→ owner onlyPOST/PATCH/DELETEon/guests,/tokens,/branding→ editor+- All
GETendpoints → viewer+
Frontend
- New "Team" tab on event detail page
- Shows current collaborators with avatars and roles
- "Invite" button opens a modal: email + role dropdown
- Pending invitations have a "Resend" + "Cancel" button
- Permission-aware UI everywhere:
- Viewers don't see edit/delete buttons (server enforces too — UI is convenience)
- Editors don't see the "Team" tab or "Delete event" button
- After accepting an invite, user lands on the event page
Tests
- Unit: role comparison helper
- Integration: invited editor can add guests; viewer gets 403 trying
- Integration: removing the last owner returns 400
- Integration: invite token is single-use and expires after 7 days
- Integration: existing single-host events still work (backfilled to owner)
Definition of done
- Migration
0005_collaborators.up.sqlapplied with backfill - All three roles enforced server-side
- Invitation email arrives, accept link works for existing + new users
- Team tab functional for all role levels (with appropriate hiding)
- No regressions in single-host event flow
useHost()/ current user context exposes the role per event so the UI can branch
Effort: ~1.5–2 weeks.
Block D — Event branding
Why after multi-host: editors need permission to edit branding. The role check from Block C is the gate.
Goal
Hosts customise how their RSVP pages look — primary colour, logo, cover image, accent colour. Custom domains are an optional sub-block (see "Open questions").
Schema changes
Migration 0006_branding.up.sql:
CREATE TABLE event_branding (
event_id UUID PRIMARY KEY REFERENCES events(id) ON DELETE CASCADE,
primary_color TEXT, -- e.g. '#22c55e'
accent_color TEXT,
logo_url TEXT,
cover_image_url TEXT,
font_family TEXT, -- one of a curated allowlist
greeting_message TEXT, -- "Looking forward to seeing you!"
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- Custom domain support (optional sub-block)
CREATE TABLE custom_domains (
domain TEXT PRIMARY KEY,
event_id UUID NOT NULL REFERENCES events(id) ON DELETE CASCADE,
verified BOOLEAN NOT NULL DEFAULT FALSE,
verified_at TIMESTAMPTZ,
txt_challenge TEXT NOT NULL, -- random nonce host adds as DNS TXT record
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
Backend
- File-upload service (
internal/uploads/):POST /uploads/image— multipart, accepts PNG/JPEG, max 2MB, scales/ re-encodes viagithub.com/disintegration/imagingfor consistency- Stores in S3-compatible bucket (configurable via
GG_S3_*env vars; use local MinIO container for dev) - Returns the public CDN URL
PUT /events/{id}/branding— upsert branding row (editor+)GET /events/{id}/branding— read (any role)GET /access/{token}— include the event's branding in the response so the RSVP page can render with it- Colour validation: must be valid hex; logos/covers must be from our own CDN domain (don't allow arbitrary URLs — XSS surface)
- Custom domain sub-block (optional, can defer):
POST /events/{id}/custom-domain— register, returns TXT challengePOST /events/{id}/custom-domain/verify— checks DNS, marks verified- Server-side: dynamic ingress route that resolves a domain to its event_id and serves the RSVP page (this is heavy; needs Traefik / ingress config — likely a k8s task, not pure Claude scope)
Frontend
- "Branding" tab on event page (editor+)
- Colour pickers (use
@vueform/colorpickeror HTML5 input) - Logo upload + preview, cover image upload + preview
- Font picker (curated list: Inter, Playfair Display, Cormorant, etc. — keeps load times sane)
- Live preview pane on the right (re-uses the invitation card mockup component we already built — pass branding props)
- RSVP page reads branding from
/accessresponse, applies via CSS vars
Tests
- Unit: hex colour validation, image size/format validation
- Integration: upload → URL returned → branding row links it
- Integration: RSVP page renders with custom colours
Definition of done
- Logo + colours customisable and preserved
- RSVP page reflects branding on load (no flash of default colours)
- Image upload caps enforced server-side
- Invitation emails (from Tier 1 Block D) also honour branding
- Custom domain sub-block: decision made (ship or defer to Tier 3)
Effort: ~1 week without custom domain, ~1.5 weeks with.
Block E — Host analytics
Why now: hosts have enough data once events have been running for a week. Pure read-side feature, no schema changes, parallel-safe with D and G.
Goal
A polished "Analytics" tab per event showing the numbers hosts actually care about: response rate over time, who hasn't opened their invitation, time-to-respond, plus-one breakdown.
Schema changes
None directly. To support source/channel attribution later, add a utm_source
column to tokens or guests (cheap and useful):
ALTER TABLE tokens ADD COLUMN utm_source TEXT;
(Set when the host issues the token via a labelled link.)
Backend
GET /events/{id}/analytics(any role) — returns:- Overview: total invited, confirmed, declined, maybe, no-response
- Response rate: daily counts of new responses since invitation went out
- Funnel: invited → opened (at least one access_log) → responded
- Time-to-respond histogram: buckets of 0–1h, 1–24h, 1–3d, 3–7d, 7d+
- Plus-one distribution: count of RSVPs with 0, 1, 2, 3+ plus-ones
- Channel attribution: response counts grouped by
utm_sourceif set - Stale guests: list of guests who haven't opened their link, sorted by oldest invitation first
- All counts derived from existing tables via aggregation queries; cache in Redis with 60-second TTL since the same dashboard is hit repeatedly
GET /events/{id}/analytics/export.csv— flat export of the guest list with response, time-to-respond, plus-ones, and source columns
Frontend
- "Analytics" tab on event page
- Charts via
chart.js+vue-chartjs:- Line chart for response rate over time
- Donut for response breakdown
- Bar chart for time-to-respond histogram
- Stale-guests table with a "Send a reminder" button (links into Block F when that ships; until then, just a "Copy link" affordance)
- CSV export button
Tests
- Unit: aggregation logic on synthetic data (matches expected counts)
- Integration: analytics endpoint returns sensible numbers on a seeded event
- Integration: cache invalidation on new RSVP (or accept up-to-60s staleness)
Definition of done
- Five charts rendering with real data
- CSV export works in Excel + Numbers
- Stale guests list correct
- Cache layer reduces DB load (verified via logs / Postgres
pg_stat_statements)
Effort: ~1 week.
Block F — Reminders + broadcasts
Why late: depends on Tier 1 Block D (notifications) being rock-solid. Sending the wrong reminder to 200 wedding guests is a reputational incident. Also depends on Block C so editors can compose broadcasts without elevated permission.
Goal
- Auto-reminders: 7 days before, 1 day before, day-of (configurable on/off per event)
- "Last call": 3-day pre-event nudge to guests who still haven't responded
- Custom broadcasts: host writes a message, picks an audience (all / attending / pending / declined / maybe), sends now or schedules
Schema changes
Migration 0007_messages.up.sql:
CREATE TYPE message_audience AS ENUM ('all', 'attending', 'pending', 'declined', 'maybe');
CREATE TYPE message_channel AS ENUM ('email', 'sms', 'both');
CREATE TYPE message_status AS ENUM ('draft', 'scheduled', 'sending', 'sent', 'cancelled', 'failed');
CREATE TABLE scheduled_messages (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
event_id UUID NOT NULL REFERENCES events(id) ON DELETE CASCADE,
send_at TIMESTAMPTZ NOT NULL,
audience message_audience NOT NULL,
channel message_channel NOT NULL,
template_key TEXT, -- 'reminder_7d' | 'last_call' | NULL for custom
subject TEXT,
body TEXT NOT NULL,
status message_status NOT NULL DEFAULT 'draft',
sent_at TIMESTAMPTZ,
recipient_count INT,
created_by UUID REFERENCES users(id),
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX idx_messages_due ON scheduled_messages(send_at)
WHERE status = 'scheduled';
-- Track per-recipient delivery so a single failed send doesn't fail the batch
CREATE TABLE message_deliveries (
message_id UUID NOT NULL REFERENCES scheduled_messages(id) ON DELETE CASCADE,
guest_id UUID NOT NULL REFERENCES guests(id) ON DELETE CASCADE,
status TEXT NOT NULL, -- 'pending' | 'sent' | 'bounced' | 'skipped'
sent_at TIMESTAMPTZ,
error TEXT,
PRIMARY KEY (message_id, guest_id)
);
Backend
- New repo
storage.MessageRepo - Endpoints (editor+):
POST /events/{id}/messages— create scheduled or send-now messageGET /events/{id}/messages— listPATCH /events/{id}/messages/{message_id}— edit (only whilescheduled)POST /events/{id}/messages/{message_id}/send-now— promote scheduled → sendingDELETE /events/{id}/messages/{message_id}— cancel
- Scheduler worker (extend
cmd/notifier):- Polls
scheduled_messages WHERE status = 'scheduled' AND send_at <= now()every 30s - Picks one, marks
sending, fans out to recipients filtered by audience - Uses existing notification infra (Twilio + SES from Tier 1 Block D)
- Per-recipient row in
message_deliveries; failures recorded, not retried (keep failures visible to host) - Marks parent
sentwhen all deliveries complete
- Polls
- Auto-reminder seeding: on event creation (or when first guest added),
insert
scheduled_messagesrows forreminder_7d,reminder_1d,last_call(3d, pending audience). Host can edit or cancel before they fire. Skip if event date is too close. - Template variables:
{{guest_name}},{{event_name}},{{event_date}},{{venue}},{{rsvp_link}}— rendered server-side per recipient
Frontend
- "Communications" tab on event page (editor+)
- "Compose new message" form with:
- Audience selector (all / attending / pending / etc.) with live recipient count
- Channel selector (email / SMS / both — SMS gated by Pro tier)
- Subject + body fields with variable insertion buttons
- Preview pane that renders one recipient as an example
- "Send now" or "Schedule for…" datetime picker
- Tabs within the section: Scheduled · Sent · Drafts
- Sent list shows delivery counts (
200 of 203 delivered) with per-row drill-down - Click on a scheduled auto-reminder to edit or disable
Tests
- Integration: scheduled message fires within 1 minute of
send_at - Integration: audience filter correctly excludes guests
- Integration: cancelled message doesn't send
- Integration: variable substitution renders correctly
- Unit: scheduler skips events whose date is in the past
Definition of done
- Auto-reminders fire correctly on a seeded event
- Custom broadcasts deliverable to filtered audience
- Recipient count accurate before send
- Per-recipient delivery status visible to host
- SMS billing meter increments correctly (Tier 1 Block F usage counter)
- Unsubscribe header respected (don't email opted-out addresses)
Effort: ~1.5–2 weeks.
Block G — Smarter fraud detection
Why now, not Tier 1: the current scorer works well enough for launch (it catches forwarded links). Hosts at scale will start asking "why was my aunty flagged?" — and we'll need the tools in this block to answer.
Goal
Reduce false positives we already know about (the consecutive 0 → 61
scoring artefact), let hosts tune thresholds for their event, and start
collecting labelled data for a future ML model.
Schema changes
Migration 0008_fraud_v2.up.sql:
ALTER TABLE access_logs
ADD COLUMN geo_country TEXT,
ADD COLUMN geo_city TEXT,
ADD COLUMN geo_lat DOUBLE PRECISION,
ADD COLUMN geo_lon DOUBLE PRECISION;
ALTER TABLE events
ADD COLUMN fraud_medium_threshold SMALLINT NOT NULL DEFAULT 30,
ADD COLUMN fraud_high_threshold SMALLINT NOT NULL DEFAULT 60,
ADD COLUMN fraud_block_threshold SMALLINT NOT NULL DEFAULT 85;
CREATE TABLE fraud_feedback (
access_log_id UUID PRIMARY KEY REFERENCES access_logs(id) ON DELETE CASCADE,
verdict TEXT NOT NULL CHECK (verdict IN ('legitimate', 'suspicious')),
marked_by UUID REFERENCES users(id),
note TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE event_allowlists (
event_id UUID NOT NULL REFERENCES events(id) ON DELETE CASCADE,
ip_cidr INET NOT NULL,
label TEXT, -- e.g. "Office network"
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (event_id, ip_cidr)
);
CREATE INDEX idx_allowlists_event ON event_allowlists(event_id);
Backend
- Geolocation in the fraud engine (
fraud-engine/app/geo.py):- Integrate MaxMind GeoIP2 (lite is free, paid is more accurate) or a cheap API like ipapi.com
- Cache lookups in Redis (TTL 30 days, geolocation rarely changes)
- New scoring feature
geo_jump: large distance between consecutive accesses for the same guest (> 500 km in < 1 hour ⇒ suspicious)
- Per-event thresholds: the gRPC
ScoreInputalready carriesEventID; load the event's thresholds at scoring time, cache for 60s in the engine. Apply thresholds to the band assignment instead of the hardcoded30/60/85constants inscoring.py. - Allowlist short-circuit: if the request IP matches any
event_allowlists.ip_cidrfor the event, score = 0, band = low. Implemented in the API before calling the fraud engine. - Feedback endpoint:
POST /events/{id}/access-logs/{log_id}/feedback(editor+)- Body:
{ verdict: "legitimate" | "suspicious", note?: string } - Writes to
fraud_feedback
- Allowlist management (editor+):
GET/POST/DELETE /events/{id}/allowlist
- Tightening the consecutive
0→61false-positive: changescoring.pyto require two signals (not just fingerprint_mismatch) to cross intohigh. Tune weights based on early production data.
Frontend
- New "Security" tab on event page (editor+)
- Threshold sliders with live preview ("With these settings, 12 of your current 47 access events would be flagged")
- Allowlist table with add/remove
- In the existing live monitor: each
SuspiciousorBlockedentry gets a small dropdown:- "Mark as legitimate" (sends feedback, hides the pill, updates band)
- "Confirm suspicious" (acknowledged, helps train future model)
- Optional: a "Why was this flagged?" tooltip on each entry showing the
contributing reasons (we already have
reasonsinaccess_logs)
ML model (later, not in this block)
The data captured in fraud_feedback is what a real classifier will train on.
This block is infrastructure for the model, not the model itself. Once you
have ~500 labelled rows, train a scikit-learn LogisticRegression or
GradientBoostingClassifier, ship it as a second path in scoring.py with
a feature flag.
Tests
- Unit: geo-distance calc, allowlist CIDR match, threshold band assignment
- Integration: feedback recorded, allowlist bypass works
- Integration: per-event threshold overrides default
- Manual: verify MaxMind lookup returns sensible country for known IPs
Definition of done
- Geolocation enriched on every scored access
- Per-event thresholds respected
- Allowlists bypass scoring (covers corporate offices, family Wi-Fi)
- Feedback recorded; UI lets hosts mark legitimate/suspicious
- The known consecutive-scoring false positive is reduced (measured against current data)
Effort: ~2 weeks.
Block H — Day-of check-in
Why last: most complex block, depends on a stable platform. This is the killer demo feature that wins enterprise + wedding-planner contracts.
Goal
A host (or designated check-in volunteer) opens a PWA on their phone at the venue, scans the QR code on each confirmed guest's invitation, and the dashboard updates with a live arrival count. Works offline (with queued sync) since venue Wi-Fi is unreliable.
Schema changes
Migration 0009_checkin.up.sql:
CREATE TABLE check_ins (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
guest_id UUID NOT NULL REFERENCES guests(id) ON DELETE CASCADE,
checked_in_at TIMESTAMPTZ NOT NULL DEFAULT now(),
checked_in_by UUID REFERENCES users(id),
arrival_count INT NOT NULL DEFAULT 1, -- guest + plus-ones who showed up
notes TEXT,
walk_in BOOLEAN NOT NULL DEFAULT FALSE
);
CREATE UNIQUE INDEX idx_checkins_guest ON check_ins(guest_id);
CREATE INDEX idx_checkins_event ON check_ins(guest_id, checked_in_at);
-- The check-in payload is a signed JWT in the QR code, not a database row,
-- so no new column on `guests` needed.
Backend
- QR code generation:
- In the RSVP confirmation flow, generate a JWT signed with a per-event
HMAC secret containing
{ event_id, guest_id, exp }(exp = event date + 24h) - Encode as a QR PNG via
github.com/skip2/go-qrcode - Embed in the confirmation email and on the confirmation page so guests can show it at the door (printed or on phone)
- In the RSVP confirmation flow, generate a JWT signed with a per-event
HMAC secret containing
POST /events/{id}/check-in(editor+):- Body:
{ qr_payload: "...", arrival_count: 2 } - Validates the JWT (signature, expiry, event match)
- Inserts a
check_insrow (UNIQUE prevents duplicate check-in) - Returns guest details for the scanner UI to confirm
- Broadcasts
check_in.recordedto the WS hub
- Body:
GET /events/{id}/check-ins— live listPOST /events/{id}/walk-ins— register a walk-in (creates guest + check_in in one transaction)- New WS event type
check_in.recordedfor the live monitor + a dedicated arrivals counter widget
Frontend (PWA)
- New page
/dashboard/events/{id}/scanner(editor+) - PWA manifest + service worker so it installs to the home screen and works without a live connection
- Camera via
getUserMedia+ QR decoding viajsQR(orzxing-wasmfor better accuracy in low light) - After successful scan:
- Show the guest's name + expected plus-ones large on screen
- "X people in your party — confirm" buttons (1, 2, 3, or "Other" → input)
- Tap → POST to
/check-in→ green success, vibrate, beep - Already-checked-in → orange warning, doesn't double-record
- Offline queue: failed POSTs go into IndexedDB, retried with exponential backoff when online again. Show a small "3 scans queued" badge
- Walk-in form: button on scanner → modal with name + plus-ones → creates guest + check-in in one tap
- Live arrivals dashboard widget (on main event page): big number "47 of 85 arrived" with a thin chart of arrival rate over the last hour. Updates via WS in real time.
Tests
- Unit: JWT generation + verification with replay protection
- Integration: scan → check-in recorded → arrival count increments
- Integration: duplicate scan returns 409 with friendly message
- Integration: walk-in creates both guest and check-in in one transaction
- E2E (manual): scanner works on iOS Safari and Android Chrome with real printed QR codes; offline scan syncs when network returns
Definition of done
- Confirmation page + email include a scannable QR code
- Scanner PWA installs and works on iOS Safari + Android Chrome
- Live arrivals counter updates on the dashboard within 2 seconds
- Walk-in flow tested end-to-end
- Offline scanning queues and recovers gracefully
- Plus-one verification at the door records the actual party size
- Already-checked-in case handled (no duplicates, clear feedback)
Effort: ~2–2.5 weeks.
Cross-cutting concerns
Audit logging
Every collaborator change, branding change, message send, threshold tweak,
allowlist edit, and check-in is logged with userID, action, target_id,
request_id. Reuse the audit logging baseline from Tier 1.
Tier-gating
Several Tier 2 features should be limited by subscription tier (Tier 1 Block F):
- Free: no SMS reminders, no custom domain, max 1 collaborator (viewer only), no analytics export
- Personal: SMS reminders capped at 100/month, 1 editor + unlimited viewers, analytics export
- Pro: 1,000 SMS/month, unlimited collaborators, all branding, custom domain
- Business: unlimited SMS, multi-event, white-label branding
Enforcement lives in the middleware introduced by Tier 1 Block F's
requireTier(...) helper.
Feature flags
Each of A through H lands behind a feature flag (features.editable_rsvp,
features.checkin_pwa, etc.). Roll out to staging → beta customers →
everyone over a week or two. Flag values stored in a feature_flags table
or as env vars on the API container.
Migration discipline
- Every migration has a tested
*.down.sql - Backfills (like
event_collaboratorsfromevents.host_id) are idempotent so re-running is safe - No destructive operations without backup verification first
Open questions
Resolve early — some have material effort implications:
-
Custom domain support — Tier 2 or Tier 3?
- The application code is straightforward. The hard parts are dynamic ingress configuration, automated cert provisioning (LetsEncrypt), and DNS-challenge automation — all infra work. Recommend defer to Tier 3 unless you have a customer asking for it.
-
Calendar timezone handling — UTC or host's locale?
- UTC works correctly everywhere but looks weird ("dinner at 17:00 UTC").
Local timezone is friendlier but adds complexity. Recommend: store
event_dateasTIMESTAMPTZ(already done), display in browser locale, emit.icswith explicitTZID=derived from the host's profile.
- UTC works correctly everywhere but looks weird ("dinner at 17:00 UTC").
Local timezone is friendlier but adds complexity. Recommend: store
-
SMS reminders — opt-in by default or off?
- SMS deliverability is fragile and costs real money. Recommend: opt-in per event in the "Communications" tab, with a usage warning that references the tier limit.
-
QR check-in: pre-printed badge support?
- Some weddings hand out badges with names + QR. Same QR mechanism would work — host downloads a print-ready PDF of all QR codes for the attending list. Defer to Tier 4 (marketplace integrations).
-
Multi-host invitation expiry
- 7 days proposed in Block C. Should the invitation be reusable in case the recipient deletes the email? Probably no — resend instead. Decided.
-
Branding: can guests see the host's personal logo, or only the event's?
- Recommend event-only. Hosts may not want personal branding on a wedding RSVP page.
-
Analytics: do we expose access_log details to viewers?
- Includes IP addresses. Recommend: viewers see aggregates only; editors+ see the individual log entries.
Sequencing summary table
| Wave | Block | Depends on | Effort (1 eng) | Parallel with |
|---|---|---|---|---|
| 1 | A. Editable RSVPs | — | 4d | B |
| 1 | B. Calendar integration | — | 2d | A |
| 2 | C. Multi-host | A, B done | 1.5–2w | — |
| 3 | D. Event branding | C | 1w (1.5w w/ custom domain) | E, G |
| 3 | E. Host analytics | C | 1w | D, G |
| 3 | G. Smarter fraud | C | 2w | D, E |
| 4 | F. Reminders + broadcasts | Tier 1 D + C | 2w | H |
| 4 | H. Day-of check-in | C, F (for QR-in-reminders) | 2.5w | F |
One engineer, sequential: ~11 weeks. Two engineers, parallel-where-possible: ~6.5 weeks.
What's not in Tier 2 (deliberate)
These are tempting but belong in Tier 3 or 4:
- Observability (Prometheus, OpenTelemetry tracing, Sentry) — Tier 3
- Native iOS/Android apps — PWA from Block H is enough for v1; native is Tier 3
- i18n — Tier 3 (translated templates touch every notification template)
- SSO (SAML/OIDC) — Tier 4
- Public API + webhooks — Tier 4
- CRM sync (Salesforce, HubSpot) — Tier 4
- Zapier integration — Tier 4
- AI setup assistant — Tier 4
- Marketplace integrations (caterers, photographers) — Tier 4
- Biometric / face check-in — Tier 4
Ship Tier 2 first. The story by the end of it: "personal invitations, shared with your team, branded for your event, with reminders, real arrival tracking, and security that adapts to your venue". That's a genuinely differentiated product.