Merge branch 'claude/pensive-rosalind-5631ed': Tier 2 plan

Adds docs/TIER2_PLAN.md with the detailed 8-block sequencing for
post-launch work, and updates CLAUDE.md to reference both plan docs.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Kwaku Danso
2026-05-17 17:10:15 +01:00
2 changed files with 875 additions and 1 deletions
+3 -1
View File
@@ -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 Future sessions: read this section before starting new feature work so you know
which tier you're contributing to. 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 ### Tier 1 — Launch blockers
+872
View File
@@ -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 (AH), grouped into four waves that respect dependencies.
Estimated effort: **~1012 weeks for one engineer**, **~67 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: ~34 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: ~12 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.52 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 01h, 124h, 13d, 37d, 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.52 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: ~22.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.52w | — |
| 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.