CoachLoop Master Build Document
Version 1.4 · August 2026 · Owner: Stan — v1.1 validated against official docs 2026-08-22. v1.2 = Aug 23 call (subscriptions-only Phase 1, announcements + journal promoted). v1.3 = deep-review fixes: join/subscribe identity linking specced (8.0), standing slug join vs invitations decided, create-tenant added, minors/COPPA + environments logged in 1.4, Phase-1 consistency fixes in 6/7/8. v1.4 = Expo SDK 57 (Aug 24): apps/mobile rescaffolded fresh on SDK 57 before session 2; sections 2.3b, 3, 11, and 14 updated (no upgrade session pending).
This is the single source of truth for building CoachLoop. It defines the product, the architecture, the schema, the money math, the conventions, and the build order. It carries forward two years of validated customer learning from the V1 system (rebuilt clean, no code reuse) plus every decision made during planning.
How to use this document (Claude Code):
- Read this document fully before any build session.
- The root
CLAUDE.mdshould reference this file and enforce the Golden Rules (section 4). - Build in the session order defined in section 11. Do not build ahead of the current phase.
- When this doc and an ad-hoc instruction conflict, ask. When this doc is silent, follow the official docs linked in section 14, then ask.
- Anything marked [V1 lesson] is paid-for knowledge from the previous system. Do not silently drop these.
1. The Product
1.1 What CoachLoop is
One platform for coaching businesses, starting with golf. Every coaching business ("tenant") gets a branded space inside a single app. Athletes join through the coach's invite link and land in that coach's branded experience.
The core loop (the product's reason to exist):
1Coach teaches lesson → assigns practice plan → athlete follows video drills
2at home → marks work complete → coach sees who did the reps → next lesson
3starts from reality, not a rewindEvery screen serves this loop. The frame: the coach's method owns the lesson hour; CoachLoop is the system that runs their method the other six days.
1.2 Revenue model
| Stream | Detail |
|---|---|
| Coach SaaS | 40/mo Business (adds scheduling, booking, lesson payments) |
| Athlete subscriptions | Coach sets price 50/mo. Platform fee: $5 floor + take rate on the remainder (exact math in section 7) |
| Split tiers | Coach keeps 50% → 60% (≥1,000/mo). Qualifying metric: best complete prior UTC month of paid billings. Tiers ratchet and never demote [V1 lesson] |
| Fee cap | Platform fee applies to at most the first $50 of one player's recurring revenue to one coach per UTC month, pooled across tier + add-ons [V1 lesson, #366] |
| One-off payments | Deferred to Phase 2 [Aug 23 call]: Phase 1 transacts subscriptions only; lessons keep running on the coach's existing payment rails, with CoachLoop recording what's owed (lesson log). When one-offs land: platform takes zero profit — application fee only recovers Stripe processing (ceil(amount × 2.9%) + 30¢). "We never touch your lesson revenue" is literally true |
1.3 Phases
| Phase | Scope |
|---|---|
| 1 (launch bar) | Tenancy + branding (logo + name only) + invite links, drills, plans, assignment, completion, coach accountability dashboard, messaging + announcements + player journal, swing upload + timestamped text/voice comments + recaps (free — no review payments), training-tier subscription via web checkout with the split. Subscriptions are the only money that moves in Phase 1 [Aug 23 call] |
| 2 | Business tier: availability, booking, lesson log → lesson payments, paid swing reviews, classes/camps/waitlists, packages & credits, reconciliation, calendar sync, remote lessons, long-form content vault ("Content" — Jack's "Library"), branding customization UI |
| 3 | Challenges, parent multi-profile, gamification, AI coach agent, vocabulary renaming |
| 4 | AI twin, parent-as-coach D2C product, direct-to-consumer product, multi-sport scale |
Phase discipline is a Golden Rule. V1 failed by building everything before shipping anything.
1.4 Open items (decisions in flux — do NOT encode in code)
- Coach SaaS pricing restructure [Aug 23 call]: Jack is exploring Business at 50–100/mo add-on, and/or SaaS tiers by player count. Until decided, nothing about coach tiers gets hardcoded; tier gating stays configurable.
- Lesson log shape: Phase 1.5/2 interim before booking payments — coaches record lessons and amounts owed/paid offline. Scope it with Jack before building.
- Vocabulary: the drills+plans coach tab keeps the name "Library"; Jack's long-form monetizable video vault (his "Library") ships in Phase 2 under a different name ("Content"). Confirmed direction, final name pending.
- Backfill developer: a second dev may wire Stripe during Stan's honeymoon. If so, sections 7–8 of this doc are their spec; money code follows it exactly.
- Minors / COPPA — needs a decision with Jack THIS WEEK: youth golf is a core segment, and the amended COPPA Rule has been in full effect since April 22, 2026 (verifiable parental consent before collecting personal info from under-13s, direct notice, retention limits). Pilot policy until parent multi-profile ships (Phase 3): accounts are created and owned by a parent for players under 13; the invite page states this and asks the age question. Never build under-13 self-registration.
- Environments: one production Supabase project + local dev (
supabase start) is the Phase 1 topology; Stripe test mode locally, live keys only in production env vars (Vercel / EAS / Supabase secrets — never in the repo). Error reporting (Sentry) is deliberately deferred past the pilot.
2. System Architecture
2.1 Topology
1┌─────────────────┐ ┌──────────────────────────────┐
2│ apps/mobile │ │ apps/web (Next.js) │
3│ Expo + Router │ │ (marketing)/ → coachloop.com│
4│ players+coaches │ │ [slug]/ → invite+checkout
5└───────┬─────────┘ │ portal/ → coach admin │
6 │ └──────────┬───────────────────┘
7 │ reads: supabase-js + RLS │
8 │ mutations touching money/ │
9 │ entitlements: edge functions │
10 ▼ ▼
11┌──────────────────────────────────────────────────┐
12│ Supabase: Postgres (RLS) · Auth · Storage · │
13│ Edge Functions (Deno) · pg_cron │
14└───────────────┬──────────────────────────────────┘
15 │ webhooks (signed) ▲
16 ▼ │ destination charges
17 ┌──────────────┐ ┌────────────────┐
18 │ Stripe │◄────────│ Coach Connect │
19 │ platform acct │ │ Express accts │
20 └──────────────┘ └────────────────┘2.2 Tenancy model
tenantsis the root: one row per coaching business. Branding (identity color, logo, display name, slug) lives here.- Single app, runtime tenant resolution. V1 baked
BRAND_IDinto per-tenant binaries and needed an entire build farm; we resolve the active tenant at runtime from the user's memberships. One binary, branded spaces. - Global user + per-tenant membership [V1 lesson, their most important architectural decision]:
auth.usersis a global person.tenant_membersis the authorization join: exactly one row (one role) per person per tenant. Role, coach approval, head-coach flag, per-tenant profile all live on the membership. Person-level facts (Stripe customer, Connect account, push tokens) key off the user. The dividing question: "would this differ per brand?" Yes → membership. No → user. - Kostis dedicated app (flagship exception): a second EAS build profile of the same codebase, published under the customer's own Apple/Google developer accounts. Never under ours. Max 2–3 such customers pre-team. Mechanics: convert the static
app.jsonto a dynamicapp.config.tsswitched by anAPP_VARIANTenv var (bundle identifier, app name, icon, splash per variant), pair it with a dedicatedeas.jsonbuild + submit profile, and store the customer's Apple/Google credentials against that profile viaeas credentials. Do not build this until the pilot demands it — the env-var switch drops in without restructuring.
2.3 Authorization boundary (decided, do not relitigate)
- Reads: mobile/web use
supabase-jsdirectly, guarded by RLS (section 6). - Mutations that touch money, entitlements, memberships, or cross-user state: Supabase Edge Functions using the service role, which derive the acting member server-side (JWT → user → membership in the claimed tenant) before acting. Never trust a tenant id or member id from a request body [V1 lesson].
- Simple own-data writes (mark drill complete, send message, update own profile) may go through RLS directly with strict policies.
2.3a Mobile data layer (decided)
- TanStack Query (React Query) is the server-state layer on mobile: every supabase-js read and edge-function call goes through
useQuery/useMutationwith tenant-scoped query keys (['tenant', tenantId, 'assignments', ...]). No hand-rolleduseEffectfetching, no axios — the built-infetchand supabase-js are enough. - Supabase client config for Expo:
EXPO_PUBLIC_SUPABASE_URL+EXPO_PUBLIC_SUPABASE_PUBLISHABLE_KEYin.env, holding the newsb_publishable_...key — the legacyanon/service_roleJWT keys are deprecated (removal slated end of 2026); never start a new project on them.EXPO_PUBLIC_vars are inlined at build time and visible in the shipped bundle — publishable key only; thesb_secret_...key must never appear in anyEXPO_PUBLIC_var. Session persistence uses an encrypted storage adapter passed tocreateClient: the documentedLargeSecureStorepattern (AES-256 key inexpo-secure-store, ciphertext in AsyncStorage — SecureStore rejects values over ~2 KB, so the raw session JSON can never live in it directly) or Expo's simplerexpo-sqlite/localStorageadapter. Never bare AsyncStorage for tokens. - Drill completions are the one offline-sensitive write (players practice at ranges with bad signal): queue them through React Query mutation retry rather than inventing a sync engine. Everything else is online-only in Phase 1.
2.3b Expo Go → development build boundary (decided)
The app starts in Expo Go and stays there as long as possible — but two Phase 1 features exceed what Expo Go can run:
- Android remote push — removed from Expo Go since SDK 53. Session 8's push-token registration can be written and iOS-tested early, but Android push verification requires a development build.
- Native video compression — any real compression library (e.g.
react-native-compressor) is a native module that Expo Go cannot load.
SDK 57 note (Aug 24): Expo Go on the App Store / Play Store stops at SDK 54, so "runs in Expo Go" now means the simulator/emulator clients Expo CLI installs, Expo Go on Android installed via the CLI, or a personal eas go build (TestFlight, requires the Apple Developer Program) on a physical iPhone. This does not change the session boundary below; it strengthens the case for the first development build at session 8.
Decision: sessions 1–7 (the vertical slice) run entirely in Expo Go. Compression is handled at capture time first — expo-camera / expo-image-picker quality + max-duration settings, which work in Expo Go (note: ImagePicker's videoQuality is iOS-only; on Android use expo-camera's videoQuality/videoBitrate props) — and a compression library is adopted only if pilot uploads hurt. First expo-dev-client development build lands at session 8 (push — Expo now states push notifications in general, not just Android, require a development build), which also unlocks universal links for the invite deep link (custom coachloop:// scheme works everywhere; https deep links need a build with associated domains).
2.3c Realtime (decided)
Messaging (and any future live surface) uses Broadcast from Database, not postgres_changes: a trigger on messages calls realtime.broadcast_changes() into a per-thread private channel; clients subscribe with config: { private: true } after supabase.realtime.setAuth(), authorized by RLS policies on realtime.messages keyed on realtime.topic(). Supabase now recommends Broadcast over postgres_changes for exactly this workload — postgres_changes is single-threaded per project and does not apply RLS to DELETEs. Broadcast messages expire after ~3 days; missed-message catch-up is a normal React Query refetch on reconnect, not replay. Docs: https://supabase.com/docs/guides/realtime/broadcast · https://supabase.com/docs/guides/realtime/authorization
2.4 Billing architecture (decided)
- One Stripe platform account. Every coach is a Stripe Connect connected account with the Express dashboard via controller properties — Stripe has deprecated
type: 'express'for new platforms. Create accounts withcontroller.losses.payments = 'application',controller.fees.payer = 'application',controller.stripe_dashboard.type = 'express'(immutable after creation; requirement collection stays with Stripe). Behavior is identical to legacy Express. Docs: https://docs.stripe.com/connect/migrate-to-controller-properties - Every charge is a destination charge:
on_behalf_of+transfer_data.destination= coach's account; platform cut = application fee. No payout code exists; Stripe settles to coach banks. Docs: https://docs.stripe.com/connect/destination-charges - Athlete subscriptions are sold on the web invite page (
coachloop.com/{slug}) via Stripe Checkout, then the athlete downloads the app and signs in. This is the Netflix pattern and it protects the split economics from Apple's IAP commission. The app never links out to purchase and never nags about web pricing (App Review Guideline 3.1.3). Docs: https://docs.stripe.com/payments/checkout · https://developer.apple.com/app-store/review/guidelines/ - Lesson payments (Phase 2) are person-to-person services and IAP-exempt; they can use in-app PaymentSheet.
- Gate coach payability on webhook-mirrored
charges_enabled, never on the onboarding redirect [V1 lesson]. - Pin the Stripe API version explicitly (2025-03-31.basil or later) in the SDK client and the webhook endpoint, and code to Basil-era payloads from day one — section 8.3 lists the field moves that break naive webhook handlers.
2.5 Video (decided)
- Client-side size control at upload (capture-time quality + max-duration via
expo-camera/expo-image-picker— see 2.3b for why not a compression library at MVP), Supabase Storage, signed URLs only (store object keys, never public URLs) [V1 lesson]. Confirm-after-upload: create the DB row, upload, then mark ready. Playback viaexpo-video(expo-avis deprecated — never add it). - No server transcoding at MVP. V1 shipped raw + signed proxy and it held; revisit with Mux only if the Kostis pilot shows playback pain.
- Recaps are never composited video [V1 lesson, their ffmpeg pipeline was retired]: store the swing video + coach's audio track + a timestamped annotation/scrub event JSON; replay all three in sync on-device.
- Review model: timestamped comments on the video timeline (SoundCloud pattern) + recaps. Side-by-side comparison is deferred; a video switcher covers mobile comparison later.
- Retention: players archive clips; a scheduled job hard-deletes 30-day-old archived clips including storage objects.
3. Repository & Tooling
Monorepo: pnpm workspaces + Turborepo. Docs: https://pnpm.io/workspaces · https://turborepo.com/docs
1coachloop/
2├── apps/
3│ ├── mobile/ # Expo, TypeScript, expo-router
4│ └── web/ # Next.js App Router
5│ └── app/(marketing)/ · [slug]/ · portal/
6├── packages/
7│ ├── tokens/ # design tokens (from theme/colors.ts spec)
8│ ├── types/ # generated Supabase types + domain types
9│ ├── core/ # pure business logic (money, streaks, progress) — tested
10│ └── validation/ # zod schemas shared everywhere
11├── supabase/
12│ ├── schemas/ # declarative schema files (source of truth)
13│ ├── migrations/ # generated via `supabase db diff`
14│ ├── functions/ # edge functions
15│ └── config.toml
16├── turbo.json · pnpm-workspace.yaml · tsconfig.base.json
17├── CLAUDE.md # points here; per-app CLAUDE.md in each app
18└── .github/workflows/ci.yml # install → typecheck → lint → test- Expo SDK 57 (React Native 0.86, React 19.2, unified
expo-*package versioning, New Architecture, React Compiler enabled). Rescaffolded fresh on SDK 57 on 2026-08-24, before session 2, so no upgrade session is pending. SDK upgrades remain their own task, never a casual bump. Install native packages withnpx expo install <pkg>(SDK-matched versions), never rawpnpm add. Read version-pinned docs (/versions/v57.0.0/), not/latest/. Expo Go from the app stores stops at SDK 54: run via simulator/emulator, Expo Go on Android through the CLI, oreas go/ a development build on a physical iPhone (see 2.3b). - Expo in the monorepo: SDK 52+ auto-configures Metro for monorepos (watchFolders, nodeModulesPaths) — the SDK 57 template ships no
metro.config.jsand none is needed; do not add one. Guide: https://docs.expo.dev/guides/monorepos/ - Declarative schema workflow: edit
supabase/schemas/*.sql, generate migrations withsupabase db diff -f <name>. The diff compares your schema files, not the live database — anything changed via Studio/psql is invisible to it and silently dropped, so never edit the live DB directly. Files apply in lexicographic order (override with[db.migrations] schema_pathsinconfig.toml). Known blind spots — hand-write these into the generated migration and review every diff: DML/seed data,ALTER POLICY, view grants +security_invoker, column/schema privileges, comments. Docs: https://supabase.com/docs/guides/local-development/declarative-database-schemas - Type generation:
supabase gen types typescript --local > packages/types/src/database.tson every schema change. Docs: https://supabase.com/docs/guides/api/rest/generating-types - Supabase CLI ≥ 2.81 required (current declarative diff engine,
db query,db advisors); the repo currently has 2.40.7 — upgrade before session 3. Runsupabase db advisors(or MCPget_advisors) after every schema change and fix what it flags. - Data API exposure is opt-in now: since 2026-05-30 (enforced on all projects 2026-10-30) new tables in
publicare not auto-exposed to the REST API. Session 3 must explicitly expose the tables clients read andGRANTtoanon/authenticated— RLS governs rows; exposure governs reachability. - Node 22+ is required by current supabase-js — pin CI and Vercel runtimes accordingly.
- Deploy: Vercel (web), EAS (mobile: https://docs.expo.dev/build/introduction/), Supabase (db + functions).
4. Golden Rules (enforced in every session)
- Phase discipline. Nothing from a later phase gets built early, including "just the table for it."
- Money is integer cents, everywhere, always. All money math lives in
packages/corewith unit tests before any UI uses it. - Mirror, don't own [V1 lesson]: Stripe is the system of record for money state. Local tables are webhook-maintained mirrors upserted on unique Stripe ids. Handlers are passthroughs of Stripe's status vocabulary.
- Idempotency at three layers [V1 lesson]: webhook event-id dedup table; unique upsert keys on mirrors; partial unique indexes as business-event guards paired with
ON CONFLICT DO NOTHING. - Enums are
text+ CHECK constraints generated from the zod array inpackages/validation— one source, DB and DTO can never drift [V1 lesson: their best of three styles]. No Postgres enums. - Soft delete over hard delete wherever history references the row (drills, plans, swings). Money rows never cascade-delete with users; they carry identity snapshots [V1 lesson: their flagged mistake].
- Join tables, not jsonb id arrays, for plan→drills, thread participants, and anything queried by membership [V1 lesson: their first scaling cliff].
- Tenant id from the server, never the request body. Cross-tenant access is a 403 before it is anything else.
- Components never import token primitives; they use semantic theme tokens via
useTheme(). One Spring action per screen. Gradients live in cards, never behind screens. - No
any. TypeScript strict. Domain types come from@coachloop/typesonly. - Store the player's IANA timezone on the membership and compute practice weeks server-side [V1 lesson: their client-passed week-start was a wart].
- Comments in schema files record why, not what — and get deleted when they stop being true [V1 lesson: comment drift was their main documentation hazard].
5. Phase 1 Schema
Declarative SQL for supabase/schemas/. Conventions: uuid PKs via gen_random_uuid(); timestamptz; created_at/updated_at with a shared moddatetime trigger; text+CHECK enums; snake_case. Person ids are auth.users.id (uuid). 27 tables (v1.2: payments deferred to Phase 2; announcements + journal_entries added). Every foreign key gets a covering index unless it is already the leading column of another index — Postgres does not index FKs, and cascade/RESTRICT checks plus RLS policy predicates depend on them (db advisors flags the misses; the indexes shown below are the hot ones, not the complete set). Source-of-truth handoff: at session 3 the supabase/schemas/*.sql files become the authoritative what (carrying these why-comments); this section then reduces to the table inventory + design rules and points at the files — do not maintain two copies of the DDL (rule 12 applies to this document too).
1-- 00_extensions.sql
2create extension if not exists moddatetime schema extensions;
3
4-- ═══════════════ TENANCY & IDENTITY ═══════════════
5
6-- 01_tenants.sql
7create table tenants (
8 id uuid primary key default gen_random_uuid(),
9 slug text not null unique
10 check (slug ~ '^[a-z0-9](?:[a-z0-9-]{1,46})[a-z0-9]$'),
11 name text not null,
12 sport text not null default 'golf',
13 status text not null default 'active'
14 check (status in ('active','suspended','archived')),
15 -- branding: identity color is the ONE color a tenant may override
16 brand_color text check (brand_color ~ '^#[0-9A-Fa-f]{6}$'),
17 logo_key text, -- storage object key, never a URL
18 created_by uuid references auth.users(id) on delete set null,
19 created_at timestamptz not null default now(),
20 updated_at timestamptz not null default now()
21);
22
23create table tenant_members (
24 id uuid primary key default gen_random_uuid(),
25 tenant_id uuid not null references tenants(id) on delete cascade,
26 user_id uuid not null references auth.users(id) on delete cascade,
27 role text not null check (role in ('coach','player')),
28 status text not null default 'active'
29 check (status in ('invited','active','suspended','removed')),
30 -- coach gate: pending coaches 403 on all coach routes until approved.
31 -- Semantics: the tenant creator is auto-approved head coach (create-tenant fn);
32 -- additional coaches join 'pending' and the head coach approves (portal, Phase 1-lite)
33 coach_approval text check (coach_approval in ('pending','approved','rejected')),
34 is_head_coach boolean not null default false,
35 display_name text,
36 timezone text not null default 'America/Phoenix', -- IANA; server computes weeks
37 invited_by_member_id uuid references tenant_members(id) on delete set null,
38 created_at timestamptz not null default now(),
39 updated_at timestamptz not null default now(),
40 unique (tenant_id, user_id) -- one role per person per tenant [V1 lesson]
41);
42create index tenant_members_user_idx on tenant_members (user_id);
43
44-- coach settings bag, 1:1 off the membership; typed columns only for what Phase 1 uses.
45-- swing_review_price_cents is INTENTIONAL forward-compat (Golden Rule 1 exception,
46-- reviewed): 7.4 pins swing_reviews_enabled false in Phase 1; no UI reads the price yet
47create table coach_member_profiles (
48 member_id uuid primary key references tenant_members(id) on delete cascade,
49 swing_reviews_enabled boolean not null default false,
50 swing_review_price_cents integer not null default 2000
51 check (swing_review_price_cents between 100 and 1000000),
52 created_at timestamptz not null default now(),
53 updated_at timestamptz not null default now()
54);
55
56-- 02_invitations.sql
57-- raw token lives only in the link; DB stores the hash [V1 lesson]
58create table invitations (
59 id uuid primary key default gen_random_uuid(),
60 tenant_id uuid not null references tenants(id) on delete cascade,
61 token_hash text not null unique, -- sha256 of the raw token
62 email text, -- optional pre-address
63 role text not null check (role in ('coach','player')),
64 coach_member_id uuid references tenant_members(id) on delete set null, -- pre-wired coach for player invites
65 status text not null default 'pending'
66 check (status in ('pending','accepted','revoked','expired')),
67 expires_at timestamptz not null,
68 accepted_by uuid references auth.users(id) on delete set null,
69 accepted_at timestamptz,
70 created_by_member_id uuid references tenant_members(id) on delete set null,
71 created_at timestamptz not null default now()
72);
73
74-- 03_relationships.sql
75create table coach_student_relationships (
76 id uuid primary key default gen_random_uuid(),
77 tenant_id uuid not null references tenants(id) on delete cascade,
78 coach_member_id uuid not null references tenant_members(id) on delete cascade,
79 player_member_id uuid not null references tenant_members(id) on delete cascade,
80 kind text not null default 'primary' check (kind in ('primary','additional')),
81 status text not null default 'active' check (status in ('active','ended')),
82 created_at timestamptz not null default now(),
83 updated_at timestamptz not null default now()
84);
85-- DB-enforced: at most one ACTIVE PRIMARY coach per student [V1 lesson]
86create unique index csr_one_primary on coach_student_relationships
87 (tenant_id, player_member_id) where (kind = 'primary' and status = 'active');
88create unique index csr_pair_active on coach_student_relationships
89 (coach_member_id, player_member_id) where (status = 'active');
90
91-- ═══════════════ COACHING CORE ═══════════════
92
93-- 04_drills.sql
94create table drills (
95 id uuid primary key default gen_random_uuid(),
96 tenant_id uuid not null references tenants(id) on delete cascade,
97 coach_member_id uuid not null references tenant_members(id) on delete cascade,
98 title text not null,
99 category text not null, -- shared constant list in packages/validation
100 difficulty text not null default 'beginner'
101 check (difficulty in ('beginner','intermediate','advanced')),
102 kind text not null check (kind in ('rep','time','video')),
103 target_reps integer check (target_reps > 0),
104 target_duration_s integer check (target_duration_s > 0),
105 description text,
106 -- ordered cues; each {text, at_s?} — at_s times the cue to the demo video;
107 -- runner falls back to even distribution when at_s is null [V1 lesson]
108 cues jsonb not null default '[]',
109 video_key text,
110 poster_key text,
111 voiceover_key text,
112 audio_mode text not null default 'original'
113 check (audio_mode in ('original','voiceover','mute')),
114 archived_at timestamptz, -- soft delete; plans keep working
115 created_at timestamptz not null default now(),
116 updated_at timestamptz not null default now(),
117 check (kind <> 'rep' or target_reps is not null),
118 check (kind <> 'time' or target_duration_s is not null)
119);
120create index drills_coach_idx on drills (tenant_id, coach_member_id, created_at desc);
121
122-- 05_plans.sql
123-- Templates + clone-on-assign [V1 lesson]: assignment always clones so bespoke
124-- edits never mutate the shared template. duration lives on the ASSIGNMENT.
125create table plans (
126 id uuid primary key default gen_random_uuid(),
127 tenant_id uuid not null references tenants(id) on delete cascade,
128 coach_member_id uuid not null references tenant_members(id) on delete cascade,
129 title text not null,
130 notes text,
131 is_template boolean not null default true,
132 source_plan_id uuid references plans(id) on delete set null, -- clone provenance
133 archived_at timestamptz,
134 created_at timestamptz not null default now(),
135 updated_at timestamptz not null default now()
136);
137
138create table plan_sessions (
139 id uuid primary key default gen_random_uuid(),
140 plan_id uuid not null references plans(id) on delete cascade,
141 position integer not null check (position >= 1),
142 label text not null,
143 notes text,
144 unique (plan_id, position)
145);
146
147-- real join table with ordering — replaces V1's jsonb drills array [V1 lesson §11]
148create table plan_items (
149 id uuid primary key default gen_random_uuid(),
150 plan_session_id uuid not null references plan_sessions(id) on delete cascade,
151 drill_id uuid not null references drills(id) on delete restrict, -- drills soft-delete
152 position integer not null check (position >= 1),
153 reps_override integer check (reps_override > 0),
154 duration_override_s integer check (duration_override_s > 0),
155 coach_note text,
156 unique (plan_session_id, position)
157);
158create index plan_items_drill_idx on plan_items (drill_id); -- RESTRICT check on drill archive/delete
159
160create table plan_assignments (
161 id uuid primary key default gen_random_uuid(),
162 tenant_id uuid not null references tenants(id) on delete cascade,
163 plan_id uuid not null references plans(id) on delete cascade, -- the clone
164 player_member_id uuid not null references tenant_members(id) on delete cascade,
165 assigned_by_member_id uuid references tenant_members(id) on delete set null,
166 starts_at timestamptz not null default now(),
167 duration_weeks integer not null default 1 check (duration_weeks between 1 and 8),
168 status text not null default 'active'
169 check (status in ('active','completed','archived')),
170 completed_at timestamptz,
171 created_at timestamptz not null default now(),
172 updated_at timestamptz not null default now()
173);
174create index pa_player_active on plan_assignments (player_member_id) where (status = 'active');
175-- pg_cron: complete active assignments past starts_at + duration_weeks; notify player.
176
177-- 06_completions.sql
178-- every runner run-through. Links are detachable: deleting a plan keeps the
179-- player's logged history [V1 lesson] — it just stops counting toward progress.
180create table drill_completions (
181 id uuid primary key default gen_random_uuid(),
182 tenant_id uuid not null references tenants(id) on delete cascade,
183 player_member_id uuid not null references tenant_members(id) on delete cascade,
184 drill_id uuid not null references drills(id) on delete restrict,
185 assignment_id uuid references plan_assignments(id) on delete set null,
186 plan_item_id uuid references plan_items(id) on delete set null,
187 reps_done integer check (reps_done >= 0),
188 duration_s integer check (duration_s >= 0),
189 started_at timestamptz not null default now(),
190 completed_at timestamptz,
191 created_at timestamptz not null default now()
192);
193create index dc_player_time on drill_completions (player_member_id, completed_at desc);
194create index dc_assignment on drill_completions (assignment_id) where (assignment_id is not null);
195create index dc_drill on drill_completions (drill_id); -- RESTRICT check
196-- Progress: best completed run per plan_item wins within the player's local week
197-- (weeks computed SERVER-side from tenant_members.timezone). Rep writes monotonic:
198-- max(new, existing) — you cannot un-complete within a week [V1 lesson].
199-- Streaks are WEEKLY (product decision), anchored current-or-previous week so a
200-- streak never blanks before the player practices [V1 anchoring lesson, our unit].
201
202-- ═══════════════ VIDEO ═══════════════
203
204-- 07_swings.sql
205create table swings (
206 id uuid primary key default gen_random_uuid(),
207 tenant_id uuid not null references tenants(id) on delete cascade,
208 player_member_id uuid references tenant_members(id) on delete set null, -- nullable: coach "just records" [V1]
209 coach_member_id uuid not null references tenant_members(id) on delete cascade,
210 video_key text not null,
211 poster_key text,
212 camera_view text not null default 'other'
213 check (camera_view in ('down_the_line','face_on','overhead','other')),
214 status text not null default 'uploading'
215 check (status in ('uploading','ready','reviewed','failed')),
216 review_requested boolean not null default false, -- true = "send to coach", false = silent share
217 reviewed_at timestamptz,
218 duration_s numeric,
219 trim_in_s numeric, -- non-destructive trim: clamp playback, never re-encode [V1]
220 trim_out_s numeric,
221 archived_at timestamptz, -- cron hard-deletes 30-day-old archived clips + objects
222 created_at timestamptz not null default now(),
223 updated_at timestamptz not null default now()
224);
225create index swings_coach_queue on swings (coach_member_id, created_at desc)
226 where (review_requested = true and reviewed_at is null);
227
228-- timestamped review comments on the timeline (the SoundCloud model);
229-- text or a short voice clip pinned to a moment [feature map: text/voice notes]
230create table swing_comments (
231 id uuid primary key default gen_random_uuid(),
232 swing_id uuid not null references swings(id) on delete cascade,
233 author_member_id uuid not null references tenant_members(id) on delete cascade,
234 at_s numeric not null check (at_s >= 0),
235 body text,
236 audio_key text, -- storage object key for a voice note
237 audio_duration_s numeric check (audio_duration_s > 0),
238 created_at timestamptz not null default now(),
239 check (body is not null or audio_key is not null)
240);
241create index swing_comments_swing on swing_comments (swing_id, at_s);
242
243-- narrated recap: audio + timestamped draw/scrub events, replayed in sync
244-- on-device. NEVER server-composited [V1 lesson: retired ffmpeg pipeline].
245create table recaps (
246 id uuid primary key default gen_random_uuid(),
247 swing_id uuid not null references swings(id) on delete cascade,
248 coach_member_id uuid not null references tenant_members(id) on delete cascade,
249 audio_key text,
250 events jsonb not null default '[]', -- [{t, type, geometry|seek, color}]
251 duration_s numeric,
252 status text not null default 'draft' check (status in ('draft','final')),
253 finalized_at timestamptz,
254 created_at timestamptz not null default now(),
255 updated_at timestamptz not null default now()
256);
257
258-- ═══════════════ MESSAGING ═══════════════
259
260-- 08_messaging.sql — participants are a JOIN TABLE [V1 lesson §12: their cliff]
261create table threads (
262 id uuid primary key default gen_random_uuid(),
263 tenant_id uuid not null references tenants(id) on delete cascade,
264 kind text not null default 'dm' check (kind in ('dm')),
265 last_message_at timestamptz,
266 created_at timestamptz not null default now()
267);
268
269create table thread_participants (
270 thread_id uuid not null references threads(id) on delete cascade,
271 member_id uuid not null references tenant_members(id) on delete cascade,
272 joined_at timestamptz not null default now(),
273 primary key (thread_id, member_id)
274);
275create index tp_member on thread_participants (member_id);
276
277create table messages (
278 id uuid primary key default gen_random_uuid(),
279 thread_id uuid not null references threads(id) on delete cascade,
280 author_member_id uuid not null references tenant_members(id) on delete cascade,
281 body text,
282 attachment jsonb, -- {type:'swing'|'drill'|'recap', id} — validated in zod
283 created_at timestamptz not null default now(),
284 check (body is not null or attachment is not null)
285);
286create index messages_thread_time on messages (thread_id, created_at desc);
287
288create table thread_reads (
289 thread_id uuid not null references threads(id) on delete cascade,
290 member_id uuid not null references tenant_members(id) on delete cascade,
291 last_read_at timestamptz not null default now(),
292 primary key (thread_id, member_id)
293);
294
295-- coach broadcast to their students [promoted to Phase 1 per Jack, Aug 23];
296-- read-only for players, surfaced in the Messages tab
297create table announcements (
298 id uuid primary key default gen_random_uuid(),
299 tenant_id uuid not null references tenants(id) on delete cascade,
300 coach_member_id uuid not null references tenant_members(id) on delete cascade,
301 title text,
302 body text not null,
303 published_at timestamptz not null default now(),
304 created_at timestamptz not null default now()
305);
306create index announcements_tenant_time on announcements (tenant_id, published_at desc);
307
308-- player practice journal [promoted to Phase 1 per Jack, Aug 23]:
309-- player writes (entry point on Today), coach reads from the player profile
310-- before the next lesson. Not chat — reflection the coach can see.
311create table journal_entries (
312 id uuid primary key default gen_random_uuid(),
313 tenant_id uuid not null references tenants(id) on delete cascade,
314 player_member_id uuid not null references tenant_members(id) on delete cascade,
315 body text not null,
316 created_at timestamptz not null default now(),
317 updated_at timestamptz not null default now()
318);
319create index journal_player_time on journal_entries (player_member_id, created_at desc);
320
321-- ═══════════════ MONEY (Phase 1 minimum — subscriptions ONLY, per Aug 23 call) ═══════════════
322
323-- 09_money.sql
324-- person-level Stripe identities [V1 lesson: one customer reused across coaches]
325create table stripe_customers (
326 user_id uuid primary key references auth.users(id) on delete cascade,
327 stripe_customer_id text not null unique,
328 created_at timestamptz not null default now()
329);
330
331create table coach_stripe_accounts (
332 user_id uuid primary key references auth.users(id) on delete cascade,
333 stripe_account_id text not null unique,
334 charges_enabled boolean not null default false, -- gate on THESE, never the redirect [V1]
335 payouts_enabled boolean not null default false,
336 details_submitted boolean not null default false,
337 disabled_reason text,
338 updated_at timestamptz not null default now()
339);
340
341-- the coach's Training Tier price. Price changes mint a NEW Stripe Price;
342-- existing subscribers keep their snapshot — no retroactive re-billing [V1].
343create table coach_pricing (
344 member_id uuid primary key references tenant_members(id) on delete cascade,
345 monthly_price_cents integer not null check (monthly_price_cents between 500 and 5000),
346 stripe_product_id text,
347 stripe_price_id text,
348 created_at timestamptz not null default now(),
349 updated_at timestamptz not null default now()
350);
351
352-- subscription MIRROR (webhook-maintained). `product` is a TYPED column —
353-- V1's "NULL offer_id means Training Tier" convention was their most
354-- safety-critical filter; we make it a column instead [V1 lesson §11].
355create table subscriptions (
356 id uuid primary key default gen_random_uuid(),
357 tenant_id uuid not null references tenants(id) on delete cascade,
358 player_member_id uuid references tenant_members(id) on delete set null,
359 coach_member_id uuid references tenant_members(id) on delete set null,
360 product text not null default 'training_tier'
361 check (product in ('training_tier')), -- add-ons append later
362 stripe_subscription_id text not null unique,
363 stripe_price_id text,
364 status text not null, -- Stripe vocabulary verbatim [V1]
365 current_period_end timestamptz,
366 cancel_at_period_end boolean not null default false,
367 created_at timestamptz not null default now(),
368 updated_at timestamptz not null default now()
369);
370create index subs_player on subscriptions (player_member_id, status);
371
372-- NOTE [Aug 23 call]: the one-off `payments` mirror (paid swing reviews, lesson
373-- payments) moved to Phase 2 with the rest of one-off money — see section 13.
374-- Per Golden Rule 1, its table is NOT created in Phase 1. When built, it carries
375-- identity snapshots and never cascade-deletes with users [V1 lesson §11.6].
376
377-- per-invoice fee ledger: THE pooled-cap state, THE tier-qualification metric,
378-- THE recurring-revenue source [V1 lesson §5.8 — without it, exact MRR is unanswerable]
379create table subscription_invoice_fees (
380 id uuid primary key default gen_random_uuid(),
381 stripe_invoice_id text not null unique,
382 stripe_subscription_id text not null,
383 tenant_id uuid references tenants(id) on delete set null,
384 coach_member_id uuid references tenant_members(id) on delete set null,
385 player_user_id uuid references auth.users(id) on delete set null,
386 period_start timestamptz not null,
387 period_end timestamptz not null,
388 amount_cents integer not null check (amount_cents >= 0),
389 application_fee_cents integer not null check (application_fee_cents >= 0),
390 refunded_cents integer not null default 0 check (refunded_cents >= 0),
391 paid_at timestamptz,
392 created_at timestamptz not null default now()
393);
394create index sif_pool on subscription_invoice_fees
395 (coach_member_id, player_user_id, period_start);
396
397-- webhook idempotency: insert-first claim; handler throw releases for redelivery [V1]
398create table processed_webhook_events (
399 event_id text primary key,
400 processed_at timestamptz not null default now()
401);
402-- pg_cron: delete rows older than 90 days [V1 lesson §12.3: they never pruned].
403
404-- 10_push.sql
405create table push_tokens (
406 id uuid primary key default gen_random_uuid(),
407 user_id uuid not null references auth.users(id) on delete cascade,
408 token text not null unique,
409 platform text not null check (platform in ('ios','android')),
410 updated_at timestamptz not null default now()
411);Apply the moddatetime trigger to every table with updated_at. Seed data: one demo tenant, one coach, three players, six drills, one template plan, one active assignment.
6. Row Level Security
Docs: https://supabase.com/docs/guides/database/postgres/row-level-security
Strategy: RLS is the security model for direct reads and own-data writes; edge functions (service role) handle everything else. Every table gets enable row level security.
1-- helper functions (security definer, in a private schema, STABLE)
2create schema if not exists private;
3
4create or replace function private.member_id(t uuid)
5returns uuid language sql stable security definer set search_path = '' as $$
6 select id from public.tenant_members
7 where tenant_id = t and user_id = (select auth.uid()) and status = 'active'
8$$;
9
10create or replace function private.is_coach(t uuid)
11returns boolean language sql stable security definer set search_path = '' as $$
12 select exists (select 1 from public.tenant_members
13 where tenant_id = t and user_id = (select auth.uid())
14 and role = 'coach' and coach_approval = 'approved' and status = 'active')
15$$;Policy pattern per table (Claude Code writes the full set):
| Table group | Read | Write |
|---|---|---|
| tenants | any active member of the tenant | edge functions only |
| tenant_members | members of the same tenant | own row limited fields; else edge functions |
| drills, plans, plan_sessions, plan_items | tenant members (players see non-archived) | owning coach |
| plan_assignments | the player + their coaches | coaches (assign); completion status via player actions |
| drill_completions | own rows + coaches of that player | player inserts own (append-only; best run derived at read — no update path) |
| swings, swing_comments, recaps | participants (player + coach) | player uploads own; coach comments/recaps |
| threads/messages | participants only (via thread_participants join) | participants |
| announcements | tenant members | coaches of the tenant |
| journal_entries | the player (own rows) + coaches of that player | player inserts/updates own only |
| money tables | own rows (player sees own subs; coach sees own earnings) | edge functions only, no exceptions |
Rules:
- Policies use the helper functions (so the membership subquery is written once) and always name their role with
TO authenticated— neverauth.role()(deprecated, and broken under anonymous sign-ins). - Wrap
auth.uid()as(select auth.uid())for plan caching, and index every column a policy filters on. UPDATEpolicies carry bothUSINGandWITH CHECK(withoutWITH CHECKa row can be reassigned to another user), and an UPDATE silently matches 0 rows without a SELECT policy — the pgTAP suite asserts both.- The
privateschema is never in the API's exposed schemas. GrantUSAGEon it plusEXECUTEon the helpers toauthenticated; revoke fromanonandPUBLIC(Postgres grantsEXECUTEtoPUBLICby default on new functions). - Recursion trap: policies on
thread_participantsmust not querythread_participants(infinite RLS recursion). Add aprivate.is_thread_participant(thread_id uuid)security-definer helper and use it for threads, messages, and thread_reads. - Test policies with
supabase test dbpgTAP tests (supabase test new <name>.testscaffolds them). - Storage buckets (
drill-videos,swings,recap-audio,brand-assets) get mirror policies onstorage.objects; all access viacreateSignedUrl. Creating a signed URL itself requires a SELECT policy on the object, and upsert needs INSERT + SELECT + UPDATE. - Tables are exposed to the Data API explicitly (section 3). Money tables and
processed_webhook_eventsare never exposed — edge-function-only, RLS enabled anyway as defense in depth. Practical consequence: subscription status and coach earnings reach clients through edge-function endpoints (get-entitlements,get-earnings), not supabase-js reads — the policy-table read grants above are the defense-in-depth layer, not the access path.
7. Money Math Specification (packages/core, fully unit-tested)
All functions pure, integer cents in, integer cents out.
7.1 Subscription platform fee [V1 lesson §5.2, verified against the public site]
1rate ∈ {0.50, 0.40, 0.30} // platform's share of the post-floor remainder
2feeUncapped(x) = x <= 500 ? x : 500 + round((x − 500) × rate)
3pooledFee(total) = feeUncapped(min(total, 5000)) // $50/player/coach/month cap
4perInvoiceFee = clamp(pooledFee(consumed + invoice) − pooledFee(consumed), 0, invoice)consumed= sum of this player's paid invoice amounts to this coach in the current UTC month (querysubscription_invoice_fees). Order-independent differencing.- Enforcement point is
invoice.created: rewrite the draft invoice's application fee toperInvoiceFee(Stripe subscriptions only take a percent at create time; the per-invoice rewrite is what makes the floor+cap exact) [V1 lesson]. - Never implement discounts as Stripe Coupons — a coupon silently dilutes the $5 floor. Mint a real discounted Price [V1 lesson, algebra preserved in their docblock].
- Cost note: Connect subscriptions run on Stripe Billing pricing — Stripe takes its Billing fee on each invoice in addition to card processing. Platform cost; never passed to the coach.
Worked examples (test fixtures):
| Case | Result |
|---|---|
| $19 sub, coach at 60/40 | fee = 500 + 1400×0.40 = 1060¢; coach nets 840¢ = $8.40 (matches the public site's example) |
| $19 sub, 50/50 | fee = 500 + 1400×0.50 = 1200¢; coach nets 700¢ |
| $80 pooled in one month, 50/50 | pooledFee = feeUncapped(5000) = 2750¢ total for the month, regardless of how invoices split |
| $4 sub | fee = 400¢ (floor consumes it) |
7.2 Volume tiers
rate resolution: explicit per-coach override → tenant default → volume tier.
Tier metric: the coach's best complete prior UTC calendar month of paid subscription billings net of refunds (subscription_invoice_fees, in-progress month excluded). ≥ 1,000 → 0.30. Best-month-ever ⇒ tiers ratchet, never demote. Recompute monthly via pg_cron; store the current rate per coach.
7.3 One-off payments (Phase 2 — deferred per Aug 23 call; spec preserved)
applicationFee = ceil(amount × 0.029) + 30 — processing recovery only, zero platform profit. The coach absorbs processing (payer gross-up toggle later in Phase 2). Nothing in this subsection is built in Phase 1.
7.4 Review payment rule (Jon's rule — takes effect in Phase 2)
Coach-initiated review = free (no gate stops a coach annotating an unpaid swing). Player-requested review with coach charging enabled = paid before it enters the queue. isReviewPaid(initiator, coachSettings) lives in packages/core. Phase 1 behavior: all reviews are free — swing_reviews_enabled stays false platform-wide and no payment gate exists yet; the request queue works identically.
7.5 Streaks & progress (also packages/core)
- A plan is a weekly routine: its sessions/items are due every week, repeated for
duration_weeks— this single sentence drives progress, streaks, and the coach dashboard.planProgress(assignment, completions, tz)— best completed run per plan item within the player's local week (weeks start Monday 00:00 intenant_members.timezone); completed plans report all-time, never 0%. weeklyStreak(completions, tz)— consecutive weeks with a completed plan week, anchored current-or-previous week.
8. Stripe Integration
Edge functions (Deno), built on the withSupabase wrapper from npm:@supabase/server with declared auth modes — the current documented pattern. User-facing functions declare auth: 'user' and derive the acting member from ctx.userClaims (JWT verified locally against the project's JWKS; asymmetric signing keys are the default for new projects), using ctx.supabase (RLS-scoped) or ctx.supabaseAdmin (service role) as the operation requires. The webhook function declares auth: 'none' plus [functions.stripe-webhook] verify_jwt = false in config.toml, and verifies the Stripe signature over the raw body: const body = await req.text() → stripe.webhooks.constructEventAsync(body, sig, secret, undefined, cryptoProvider) with Stripe.createSubtleCryptoProvider(), Stripe imported via the npm:stripe specifier. Docs: https://supabase.com/docs/guides/functions/auth · https://supabase.com/docs/guides/functions/examples/stripe-webhooks · https://docs.stripe.com/webhooks
8.0 Join & subscribe flow (the identity seam — decided)
- The slug page (
coachloop.com/{slug}) is the standing, open join surface. Joining does NOT require an invitation row: the coach shares one durable link forever.invitationsare the optional pre-addressed path (coach adds a specific player/coach by email; also carries the pre-wired coach for academy routing). A tenant-level invite-only toggle is Phase 2. - Subscribe happens before an account exists (Netflix pattern), so linking is explicit:
create-checkoutstampsclient_reference_id = tenant_idandsubscription_data.metadata = { tenant_id, coach_member_id, claim_email }onto the Checkout Session (metadata propagates to the Subscription object); the success URL carries?session_id={CHECKOUT_SESSION_ID}into the signup step. - Webhooks write the
subscriptionsmirror withplayer_member_id NULL+ the claim email from metadata. Claiming: at signup/first sign-in the account-creation step passes the session_id (or the email matches), andaccept-invitation/create-membershiplinks the membership to the unclaimed subscription row — idempotent, one-shot (partial unique on claimed rows). An unclaimed subscription older than 72h triggers a nudge email; never auto-refund.
8.1 Functions
| Function | Purpose |
|---|---|
stripe-webhook | Single endpoint; claim event id in processed_webhook_events (insert, conflict-do-nothing, throw releases claim) then dispatch |
create-checkout | Invite page → Checkout Session for the coach's Training Tier price (destination charge subscription, application fee percent estimate; trial injection later). Stamps the 8.0 linking metadata + client_reference_id; success URL carries {CHECKOUT_SESSION_ID} |
create-review-payment | Phase 2 [Aug 23 call — not built in Phase 1]: player-requested paid review → PaymentIntent (destination charge, fee per 7.3) |
connect-onboarding | Create connected account (controller properties, Express dashboard — see 2.4) + Account Link for a coach |
create-tenant | Coach signup (marketing → portal): claim slug, create tenant + coach membership (auto-approved, is_head_coach = true), then hand off to connect-onboarding |
accept-invitation | Verify token hash, create membership + relationship transactionally; claims any unclaimed subscription per 8.0 |
assign-plan | Clone template → per-player plan, create assignment, notify |
8.2 Webhook events handled (Phase 1)
| Event | Effect |
|---|---|
account.updated | Re-fetch account, mirror verification booleans onto coach_stripe_accounts (ordering-safe) [V1] |
checkout.session.completed | Link session → local records. Under Basil, subscription-mode sessions create the subscription only at payment completion — read it from this event, never earlier (one-off/review sessions: Phase 2) |
customer.subscription.created/updated/deleted | Re-fetch and upsert the subscriptions mirror |
invoice.created | The fee authority: compute perInvoiceFee (7.1), rewrite the draft invoice's application fee, upsert subscription_invoice_fees |
invoice.paid | Stamp paid_at; entitlement follows mirror status + current_period_end |
invoice.payment_failed | Zero that invoice's row out of the pool [V1] |
payment_intent.succeeded | Phase 2 (one-off payments only): flip payments row (matched on our stored PI id / PI metadata — payment_intent.invoice no longer exists, see 8.3); mark swing paid |
payment_intent.payment_failed/canceled | Phase 2: advance in-flight rows; never overwrite succeeded/refunded [V1] |
charge.refunded | Phase 1 effect: update subscription_invoice_fees.refunded_cents for the invoice's row (feeds the 7.2 tier metric). Full refunds only; partial handling deferred, documented [V1] |
A 5-minute pg_cron settle sweep covers missed webhooks — Phase 1 scope: completed Checkout Sessions with no local subscription row, and subscriptions whose mirror is stale vs current_period_end (re-fetch and upsert). One-off purchase sweeping joins in Phase 2 [V1]. Cron jobs that need an edge function call it through pg_net's net.http_post (documented Supabase Cron pattern), authenticated with a secret key against a function declaring auth: 'secret'.
Push notifications (Phase 1 matrix): sends go through one send-push edge function calling the Expo Push API with stored push_tokens, checking receipts and pruning dead tokens (docs: https://docs.expo.dev/push-notifications/sending-notifications/). Events that push: new message · announcement published · plan assigned · coach "send reminders" nudge · swing review completed. Nothing else pushes in Phase 1.
8.3 API version pin (Basil) — payload rules
Pin the API version (2025-03-31.basil or later) and follow these rules — each is a field that existed pre-Basil and silently vanished from payloads:
| Old read | Basil-era rule |
|---|---|
invoice.subscription | invoice.parent.subscription_details.subscription (guard on invoice.parent.type === 'subscription_details') |
invoice.payment_intent / invoice.charge | Gone. Use the invoice.payments list, or GET /v1/invoice_payments?payment[payment_intent]=… to go the other way |
invoice.application_fee_amount (response field) | Gone from the Invoice object — but the update parameter still works: the 7.1 rewrite on invoice.created (POST /v1/invoices/{id} with application_fee_amount) is unchanged and remains Stripe's documented pattern. To read a fee back, expand the payment's PaymentIntent |
payment_intent.invoice | Gone. Correlate one-off PIs via our payments row keyed on the PI id, and set PI metadata at creation |
The draft-invoice window for the 7.1 fee rewrite is ~1 hour after invoice.created (Stripe delays auto-finalization up to 72h while the webhook endpoint isn't returning 2xx). The rewrite must land before finalization; if the invoice is no longer editable the handler throws, releasing the idempotency claim for redelivery.
9. Design System (summary — full spec in theme/colors.ts + tokens package)
- Stage: neutral near-black, whisper-cool.
void #090B0E · canvas #101316 · card #1B1E22 · raised #272A2F · line #393E42. Text#E9EAE3(APCA 93, no halation). Dark-first; light mode = paper#F8F9F1with darkened accents. - Function colors (never tenant-overridden): action Spring
#DBE532(ONE per screen, glow instead of shadow), progress Mantis#76C968, danger#FD736D, warning#FFB256, info#76B6F7. P3 variants on iOS. - Tenant brand = identity chrome only (logo tile, header accent) via
tenants.brand_color. - Gradients live in cards, never behind screens. Recipes: Under the Lights (hero cards), Momentum (streaks), Fairway (earnings), Daybreak (web/light only). Spring never touches paper.
- Type: Archivo (display) · Mona Sans (body) · Geist Mono (data). Static font files load one file per weight (
expo-font); text styles set weight viafontFamilyname, neverfontWeight(iOS synthesizes or falls back otherwise). Rounded-2xl cards, hairline borders.
Mobile theme bridge contract (session 2 builds this; enforced after):
ThemeProvider/useTheme()is the single entry point — exactly one theme source, never a second token file beside it. Screens import components; components import semantic tokens; nobody imports primitives.- Shared primitives (
Button,Card,ThemedText,EmptyState, …) define an explicit contract: variants (visual intent), sizes (mapped to spacing/type tokens, never fresh numbers), states (default / pressed / disabled / loading — every tappable element gets pressed feedback via aPressablestyle function), and astyleprop merged last (callers may override layout, never identity). When a component's props start describing content, switch tochildren. - Extraction rule: inline JSX → colocated screen component → shared component, promoted only when it appears on 2+ screens and has a nameable role. Never speculatively.
- Motion tokens live in
@coachloop/tokenstoo (fast 150mspress feedback ·base 250mselement transitions ·slow 400mssheets/screens) so animation feels related across the app. - RN idioms: glow/shadows are
boxShadowstrings (never legacyshadow*/elevationprops); non-capsule radii pair withborderCurve: 'continuous'. Theme values stay static hex (already true of Color System v3) — never feedPlatformColor/dynamic colors into Reanimated styles. - After building or changing a screen, run the four-check pass: hierarchy (type ramp, not ad-hoc sizes), proximity (
gap+ spacing tokens), repetition (corners/shadows/accents match — an escaped value moves into the theme), alignment (one screen edge padding). A screen failing the same check twice means the fix belongs in the theme or a component, not the screen.
10. App Information Architecture
- Coach tabs: Home · Library · Players · Schedule (Phase 2, hidden on core tier) · Messages. Business/settings behind avatar. ("Library" = drills + plans. Jack's long-form monetizable video vault — his word for "Library" — is a separate Phase 2 feature named "Content"; see 1.4.)
- Player tabs: Today · Progress · Book (Phase 2) · Messages.
- Journal [Aug 23 call]: entry button on the player's Today screen (top-right); coach reads entries from the player profile before the next lesson. Not a tab, not chat.
- Announcements: coach composes from Players; players read them in the Messages tab (read-only feed alongside the coach thread).
- Coach Home keeps the "everything findable at a glance" quality Jack validated with coaches [Aug 23 call]: pending queue + upcoming lessons + quick actions as clear tappable blocks with single-word/short labels. No analytics dashboards — Jack is explicitly skeptical of them; the earnings snapshot is one number.
- The two hero screens (best design effort lives here):
- Player Today: coach's brand header, Under-the-Lights plan card with progress ring, drill rows with checkmarks, one glowing Start Drill, Momentum streak card. Invite-link → seeing "here's your plan" in under a minute.
- Coach Home: "Who did the work" — roster grouped On Track (progress) / Behind Pace (warning) / Slipping (danger), pending review queue, one action: Send Reminders.
- Drill runner: story-style (demo loops, cues appear at their timed moment, tap to advance, rep counter, satisfying Mark Complete) [V1 pattern worth keeping].
- Web:
(marketing)per the landing spec;[slug]invite page = coach brand + subscribe (Checkout) + app download + deep link;portal= coach creation surfaces (bulk drill upload, drag-drop plan builder, roster).
11. Build Order (one Claude Code session each)
| # | Session | Definition of done |
|---|---|---|
| 1 | Scaffold monorepo | pnpm typecheck && pnpm test green; both apps boot; calculateSplit imported cross-package |
| 2 | Theme | tokens package + ThemeProvider/useTheme; hidden /dev/theme screen renders every token + gradient |
| 3 | Schema + RLS | CLI upgraded ≥2.81; declarative schemas → db diff -f migration; tables explicitly exposed to the Data API with role grants (section 3); pgTAP policy tests; db advisors clean; db:types generates; seed loads |
| 4 | Auth + invite (web) | [slug] page renders tenant brand (standing open join per 8.0); email OTP auth; create-tenant fn (coach signup → slug claim → auto-approved head coach); accept-invitation/membership creation claims unclaimed subscriptions (8.0); signup asks the age question and routes under-13 to parent-owned accounts (1.4 COPPA policy) |
| 5 | Mobile shell | auth, tenant-space resolution + theming, tab navs, empty states; data layer per 2.3a (Supabase client w/ secure session storage, QueryClientProvider, EXPO_PUBLIC_ env vars) |
| 6 | Library | coach: drill CRUD (record/upload → compressed → storage → confirm), plan builder (sessions + items) |
| 7 | The loop | assign-plan fn; player Today + drill runner + completion; coach Home accountability board; progress/streak from packages/core |
| 8 | Messaging | threads/participants/messages/reads; broadcast-from-database realtime (2.3c: trigger + private channels + realtime.messages RLS); push token registration; announcements (coach compose + player feed) and player journal (Today entry point + profile view). First expo-dev-client development build (2.3b): remote push does not run in Expo Go |
| 9 | Video review | swing upload, coach queue, timestamped comments (text + voice), recap record/replay — all reviews free in Phase 1 (7.4) |
| 10 | Money | Subscriptions only [Aug 23 call]: Connect onboarding, coach pricing, invite-page Checkout, webhook fn with the subscription events in 8.2 (account, checkout, subscription, invoice rows), fee math wired, earnings card. No PaymentIntents, no review payments. Reference pattern: expo/examples → with-stripe (adapt, never copy pinned versions) |
| 11 | Polish + pilot prep | seed demo tenant "Kostis Golf", empty/error/loading states, eas.json (appVersionSource: "remote", autoIncrement, production + development profiles), TestFlight via eas build -p ios --profile production --submit (EAS builds are metered — batch them) |
The vertical slice (pre-wedding demo): sessions 1–7 end-to-end — invite link → join branded space → see plan → run drill → mark complete → coach sees it on Home. Sessions 8–11 land after, money last. (The SDK-upgrade session this paragraph used to schedule already happened: the app was rescaffolded on SDK 57 on 2026-08-24, before session 2.)
12. Explicitly Out of Scope (Phase 1)
No booking/scheduling/classes, no one-off payment processing of any kind — paid swing reviews, lesson payments, packages: subscriptions are the only money that moves in Phase 1 [Aug 23 call], no credits/packages/gifts, no trials/intro pricing/per-student overrides, no challenges, no parent multi-profile, no AI anything, no CRM/investor/ops tooling, no add-on products, no long-form content vault ("Content"), no branding customization UI beyond logo + name, no drawing tools or side-by-side video, no calendar sync, no vocabulary renaming, no per-coach feature flags. (Announcements and the player journal are in Phase 1 as of v1.2.) Each exclusion has a validated V1 design waiting in section 13 — build them when their phase arrives, not before.
13. V1 Patterns Library (adopt when the phase needs them — not before)
| Pattern | Phase | The lesson |
|---|---|---|
| One-off payments mirror | 2 | The deferred payments table (removed from Phase 1 schema in v1.2): identity snapshots on every row, nullable FKs, never cascade-deletes with users [V1 lesson §11.6]; purpose enum grows (swing_review, booking, …) |
| Lesson log | 2 | Interim before booking payments [Aug 23 call]: coach records lessons + amounts owed/paid offline; the "translate what to charge" accounting view for coaches keeping their existing payment rails |
| Credit wallet | 2 | Append-only credit_ledger + maintained credit_balances with CHECK balance ≥ 0; partial-unique index per business event + ON CONFLICT DO NOTHING = double-apply is physically impossible |
| Trial grants | 2 | Consume the grant only when the webhook-mirrored subscription actually carries the trial — never at checkout creation (abandoned checkout must not burn the offer) |
| Intro pricing | 2 | Store the promise (intro_cycles 1–24), derive the expiry; persisting only the timestamp caused three documented defects |
| Per-student overrides | 2 | Dedicated pinned Stripe Price per override; base-price propagation skips overridden players |
| Booking engine | 2 | Snapshot price at booking; materialized occurrences (no client RRULE); saved-card deferred charges via SetupIntent; RESTRICT protecting paid registrations |
| Academy routing | 2 | payment_destination='academy' routes charges to head coach; coach_id keeps attribution, destination_coach_id records who was paid |
| Payer-covers-fees | 2 | total = ceil((base + 30)/(1 − 0.029)); platform absorbs estimate noise; coach nets exactly base |
| Challenges | 3 | Coach-roster-scoped only (facility-wide leaked between coaches); rep/time/none metric with none the default; proof toggle |
| Twin/RAG | 4 | Review-queue → nightly canon fold; retrieval traces need retention from day one (their unbounded-growth table #1) |
14. Reference Library
Expo / React Native — the app is SDK 57: always read /versions/v57.0.0/ docs, never /latest/ (latest tracks the newest SDK and can document APIs this project doesn't have)
- Monorepos: https://docs.expo.dev/guides/monorepos/
- Router: https://docs.expo.dev/router/introduction/
- Color themes / useColorScheme: https://docs.expo.dev/develop/user-interface/color-themes/ · https://reactnative.dev/docs/usecolorscheme
- Gradients: Expo now steers toward CSS gradients on
View(experimental_backgroundImage) overexpo-linear-gradient; pick the recipe format in session 2. https://docs.expo.dev/versions/v57.0.0/sdk/linear-gradient/ - Video (expo-video): https://docs.expo.dev/versions/v57.0.0/sdk/video/
- Camera / capture quality: https://docs.expo.dev/versions/v57.0.0/sdk/camera/ · https://docs.expo.dev/versions/v57.0.0/sdk/imagepicker/
- Notifications: https://docs.expo.dev/push-notifications/overview/
- Development builds (session 8+): https://docs.expo.dev/develop/development-builds/introduction/
- EAS Build (+ Kostis profile later): https://docs.expo.dev/build/introduction/ · dynamic app config: https://docs.expo.dev/workflow/configuration/
- Official integration examples (
with-stripe, …): https://github.com/expo/examples
Supabase
- API keys (publishable/secret — legacy anon deprecated end 2026): https://supabase.com/docs/guides/api/api-keys
- Local dev & migrations: https://supabase.com/docs/guides/local-development/overview
- Declarative schemas: https://supabase.com/docs/guides/local-development/declarative-database-schemas
- RLS: https://supabase.com/docs/guides/database/postgres/row-level-security
- Exposing tables to the Data API (now opt-in): https://supabase.com/docs/guides/api/securing-your-api
- Generating types: https://supabase.com/docs/guides/api/rest/generating-types
- Edge Functions: https://supabase.com/docs/guides/functions
- Edge Function auth (
withSupabase, auth modes): https://supabase.com/docs/guides/functions/auth - Function config (
verify_jwt=falsefor webhooks): https://supabase.com/docs/guides/functions/function-configuration - Stripe webhook example: https://supabase.com/docs/guides/functions/examples/stripe-webhooks
- Storage access control / signed URLs: https://supabase.com/docs/guides/storage/security/access-control
- Cron (pg_cron + pg_net → edge functions): https://supabase.com/docs/guides/cron
- Realtime Broadcast from Database + authorization: https://supabase.com/docs/guides/realtime/broadcast · https://supabase.com/docs/guides/realtime/authorization
- Changelog (check breaking changes each session): https://supabase.com/changelog
Stripe
- Connected accounts via controller properties (Express dashboard): https://docs.stripe.com/connect/migrate-to-controller-properties · https://docs.stripe.com/connect/design-an-integration
- Basil API changelog (invoice/PI field moves — see 8.3): https://docs.stripe.com/changelog/basil
- Destination charges: https://docs.stripe.com/connect/destination-charges
- Subscriptions with Connect (application fees): https://docs.stripe.com/connect/subscriptions
- Checkout: https://docs.stripe.com/payments/checkout
- Webhooks: https://docs.stripe.com/webhooks
- Refunds with Connect: https://docs.stripe.com/connect/direct-charges#issue-refunds
Apple / Google
- App Review Guidelines (3.1.1, 3.1.3 multiplatform/reader; 4.3 spam): https://developer.apple.com/app-store/review/guidelines/
- Google Play payments policy: https://support.google.com/googleplay/android-developer/answer/10281818
Tooling
- pnpm workspaces: https://pnpm.io/workspaces
- Turborepo: https://turborepo.com/docs
- Zod: https://zod.dev
- Vitest: https://vitest.dev
End of master document. Keep it truthful: when a decision changes, change it here first.