104e693046
- docs/TIER2_PLAN.md: detailed sequencing for the 8 blocks of Tier 2 work (editable RSVPs, calendar integration, multi-host, branding, analytics, smarter fraud, reminders + broadcasts, day-of check-in) with schema changes, endpoints, tests, and effort estimates per block. - CLAUDE.md: roadmap section now points at both TIER1_PLAN.md and TIER2_PLAN.md. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
873 lines
34 KiB
Markdown
873 lines
34 KiB
Markdown
# 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.
|