Backend Infrastructure PRD · v1.0
Backend Infrastructure PRD
Netlify hosts the static app and serverless functions; Supabase provides Postgres, realtime channels, and storage. One stack, no servers to run, everything the orb shows is derivable from Postgres.
1 · Architecture
Client (static)
grid, orb, graph — Netlify CDN
Netlify Functions
/api/* — auth, compute, lock, webhooks
Supabase Postgres
source of truth + RLS
Supabase Realtime
meeting:{id} channel → orb params, response counts
Google Calendar
OAuth, event write, decline webhooks
Resend
invites, reveals, magic links
- Clients subscribe to Supabase Realtime directly (anon key + RLS); writes go through Netlify Functions only — the service-role key never reaches the client.
- The scoring engine is a pure function inside a Netlify Function — no queue in v1; compute on a 6×~50-slot grid is milliseconds.
- Environments:
main→ production, deploy previews per PR. Supabase: separate prod + staging projects; migrations viasupabase db pushin CI.
2 · Data model
| Table | Key columns | Notes |
|---|---|---|
meetings | id, title, duration_min, window_start/end, state, quorum_mode='soft', organizer_participant_id | state mirrors the nebula machine exactly. |
participants | id, meeting_id, name, email, role, is_dm, guest_token (hashed), claimed_user_id, home_city, tz | Guests are rows, not users. claimed_user_id set on magic-link claim. |
paints | participant_id, slot_ts (UTC), level ∈ {free, if_need_be} | Slots stored UTC, rendered local (assumed pick). |
proposals | id, meeting_id, slot_ts, score, breakdown_json, status, below_quorum bool | breakdown_json is what the ⓘ renders — verbatim. |
locks | meeting_id, proposal_id, locked_at, gcal_event_id, superseded_by | superseded_by chains the visible reschedule history. |
events | id, meeting_id, type, payload, at | Append-only audit: every state change, flex ask, decline. |
graph_nodes | id, meeting_id, kind ∈ {person, artifact, blob*}, parent_meeting_id, position | blob kind + parent_meeting_id exist now — v2 blob groundwork. |
3 · Scoring engine — deterministic, versioned, LLM-free
- Candidates: cells where every required invitee painted free or if-need-be. If none → 2f negotiation path.
- Score =
w1·free_ratio + w2·all_DMs_present + w3·attendance_ratio + w4·workhours_fit − w5·if_need_be_count.workhours_fitpenalizes slots outside any participant's local 8am–7pm (the "London guard"). - Soft quorum: slots failing (all DMs + ≥50% when >3 invitees) are still proposable but flagged
below_quorum→ amber warning chip in UI. - Weights live in
scoring.v1.jsonin the repo — versioned, auditable, no drift (assumed pick). Every proposal stores the weights version inbreakdown_json. - All user-facing copy is templates keyed on breakdown fields. Nothing free-form is generated.
4 · API surface (Netlify Functions)
| Endpoint | Auth | Does |
|---|---|---|
POST /api/meetings | none → mints organizer token | Create meeting + participants; sends Resend invites. |
GET /api/meetings/:id | participant token | Role-scoped view: organizer full, invitee aggregate. |
POST /api/paints | participant token | Upsert paints; bumps realtime channel. |
POST /api/compute | organizer token | Runs scorer → proposals; state → PROPOSED. |
POST /api/lock | organizer token | Locks proposal; writes Google event; Resend confirmations. |
POST /api/flex | organizer token | Sends 2f flex ask; POST /api/flex/:id/answer for Sure/Can't. |
POST /api/participants | organizer token | Add-after-lock (2g) incl. calendar check. |
POST /api/webhooks/gcal | Google channel token | Decline detection → DESTABILIZED. |
POST /api/claim | magic-link token | Guest → account claim; re-parents participant rows. |
5 · Realtime — the orb is a subscription
- Channel
meeting:{id}broadcasts{state, responses, total, below_quorum}. Clients derive motion locally:speed = f(1 − responses/total), density ∝ responses, palette from state — one source of truth, no animation params over the wire. - Graph positions are client-side physics; only semantic drops (cluster membership changes) persist via
graph_nodes.
6 · Auth, security, retention
- Guest-first: every participant link carries a signed token (hashed at rest). Magic-link claim (via Resend) attaches a Supabase auth user and re-parents history.
- RLS enforces the privacy split (assumed pick): invitee tokens can select aggregate views only (a Postgres view with count-per-cell); organizer tokens reach per-person paints. The same policy set is the v2 blob-membership boundary.
- Google OAuth tokens: organizer-only in v1, encrypted at rest (pgsodium), minimal scopes (see Integrations §1).
- Retention: guest paints + emails anonymized 90 days after lock (assumed pick);
eventsaudit log keeps type + timestamps only after anonymization. - Rate limits on token-bearing endpoints; webhook endpoints verify Google channel tokens; no CORS beyond the site origin.