From 104e693046457100e33b1a72b8512643d29c27f7 Mon Sep 17 00:00:00 2001 From: Kwaku Danso <72142185+cloud-dev101@users.noreply.github.com> Date: Sun, 17 May 2026 17:10:04 +0100 Subject: [PATCH] docs: add Tier 2 production plan - 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 --- CLAUDE.md | 4 +- docs/TIER2_PLAN.md | 872 +++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 875 insertions(+), 1 deletion(-) create mode 100644 docs/TIER2_PLAN.md diff --git a/CLAUDE.md b/CLAUDE.md index d218f10..9c4f65f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -198,7 +198,9 @@ What follows is the trajectory from demo to a product that can be sold. Future sessions: read this section before starting new feature work so you know which tier you're contributing to. -The detailed sequencing for Tier 1 lives in `docs/TIER1_PLAN.md`. +Detailed sequencing lives in: +- `docs/TIER1_PLAN.md` — the 8 blocks of launch-blocker work +- `docs/TIER2_PLAN.md` — the 8 blocks of post-launch work (Tier 2) ### Tier 1 — Launch blockers diff --git a/docs/TIER2_PLAN.md b/docs/TIER2_PLAN.md new file mode 100644 index 0000000..8f30513 --- /dev/null +++ b/docs/TIER2_PLAN.md @@ -0,0 +1,872 @@ +# 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.