# 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.md` for project conventions and `docs/TIER1_PLAN.md` for 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`: ```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_revisions` before applying - Rejects if `edit_count >= 5` with 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 `used` in `MarkUsed` — 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.sql` applied (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"` - 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=...` - 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 `.ics` download) - "Other calendar app" (also `.ics` download) - Same buttons on the RSVP page if the guest already responded ### Tests - Unit: `.ics` output is valid (parse it with `github.com/arran4/golang-ical` in the test or just regex the required fields) - Unit: timezones encoded correctly (use `TZID=UTC` for simplicity, or derive from the host's locale — see open questions) - Manual: import the generated `.ics` into Apple Calendar and Outlook, confirm it renders right ### Definition of done - [ ] `.ics` validates 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`: ```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.CollaboratorRepo` with: `Add`, `List`, `UpdateRole`, `Remove`, `RoleFor(eventID, userID)` - New repo `storage.InviteRepo` for the invitation tokens - Endpoints: - `POST /events/{id}/collaborators` — body `{ email, role }`; creates an invitation; emits email via Tier 1's `EmailSender` - `GET /events/{id}/collaborators` — list current + pending - `PATCH /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 = userID` check with a join through `event_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 only - `POST/PATCH/DELETE` on `/guests`, `/tokens`, `/branding` → editor+ - All `GET` endpoints → 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.sql` applied 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`: ```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 via `github.com/disintegration/imaging` for 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 challenge - `POST /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/colorpicker` or 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 `/access` response, 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): ```sql 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_source` if 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`: ```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 message - `GET /events/{id}/messages` — list - `PATCH /events/{id}/messages/{message_id}` — edit (only while `scheduled`) - `POST /events/{id}/messages/{message_id}/send-now` — promote scheduled → sending - `DELETE /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 `sent` when all deliveries complete - **Auto-reminder seeding**: on event creation (or when first guest added), insert `scheduled_messages` rows for `reminder_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`: ```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 `ScoreInput` already carries `EventID`; load the event's thresholds at scoring time, cache for 60s in the engine. Apply thresholds to the band assignment instead of the hardcoded `30/60/85` constants in `scoring.py`. - **Allowlist short-circuit**: if the request IP matches any `event_allowlists.ip_cidr` for 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` → `61` false-positive**: change `scoring.py` to require *two* signals (not just fingerprint_mismatch) to cross into `high`. 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 `Suspicious` or `Blocked` entry 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 `reasons` in `access_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`: ```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) - `POST /events/{id}/check-in` (editor+): - Body: `{ qr_payload: "...", arrival_count: 2 }` - Validates the JWT (signature, expiry, event match) - Inserts a `check_ins` row (UNIQUE prevents duplicate check-in) - Returns guest details for the scanner UI to confirm - Broadcasts `check_in.recorded` to the WS hub - `GET /events/{id}/check-ins` — live list - `POST /events/{id}/walk-ins` — register a walk-in (creates guest + check_in in one transaction) - New WS event type `check_in.recorded` for 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 via `jsQR` (or `zxing-wasm` for 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_collaborators` from `events.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: 1. **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. 2. **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_date` as `TIMESTAMPTZ` (already done), display in browser locale, emit `.ics` with explicit `TZID=` derived from the host's profile. 3. **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. 4. **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). 5. **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. 6. **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. 7. **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.