Files
Kwaku Danso 104e693046 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 <noreply@anthropic.com>
2026-05-17 17:10:04 +01:00

34 KiB
Raw Permalink Blame History

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:

-- 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:

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:

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):

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:

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 061 scoring artefact), let hosts tune thresholds for their event, and start collecting labelled data for a future ML model.

Schema changes

Migration 0008_fraud_v2.up.sql:

ALTER TABLE access_logs
  ADD COLUMN geo_country TEXT,
  ADD COLUMN geo_city    TEXT,
  ADD COLUMN geo_lat     DOUBLE PRECISION,
  ADD COLUMN geo_lon     DOUBLE PRECISION;

ALTER TABLE events
  ADD COLUMN fraud_medium_threshold SMALLINT NOT NULL DEFAULT 30,
  ADD COLUMN fraud_high_threshold   SMALLINT NOT NULL DEFAULT 60,
  ADD COLUMN fraud_block_threshold  SMALLINT NOT NULL DEFAULT 85;

CREATE TABLE fraud_feedback (
  access_log_id UUID PRIMARY KEY REFERENCES access_logs(id) ON DELETE CASCADE,
  verdict       TEXT NOT NULL CHECK (verdict IN ('legitimate', 'suspicious')),
  marked_by     UUID REFERENCES users(id),
  note          TEXT,
  created_at    TIMESTAMPTZ NOT NULL DEFAULT now()
);

CREATE TABLE event_allowlists (
  event_id   UUID NOT NULL REFERENCES events(id) ON DELETE CASCADE,
  ip_cidr    INET NOT NULL,
  label      TEXT,                       -- e.g. "Office network"
  created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
  PRIMARY KEY (event_id, ip_cidr)
);

CREATE INDEX idx_allowlists_event ON event_allowlists(event_id);

Backend

  • Geolocation in the fraud engine (fraud-engine/app/geo.py):
    • Integrate MaxMind GeoIP2 (lite is free, paid is more accurate) or a cheap API like ipapi.com
    • Cache lookups in Redis (TTL 30 days, geolocation rarely changes)
    • New scoring feature geo_jump: large distance between consecutive accesses for the same guest (> 500 km in < 1 hour ⇒ suspicious)
  • Per-event thresholds: the gRPC 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 061 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:

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.