Tutor marketplace with booking, payments and messaging
Parents search tutors by subject, grade and postcode, then book and pay for lessons, with commission, ID checks and masked messaging.
nextjstypescriptsupabasepostgresstripeplaywright
Built 1 time, downloaded 0 times.
Install from your terminal
Run this in your project folder. It signs you in, shows what changes and asks before it overwrites a file.
npx claude-lab add tutor-marketplace-with-booking-payments-and-messaging-c076adCLAUDE.md# Tutor marketplace
## Purpose
This is a tutor marketplace. Parents search by subject, grade and postcode,
view tutor profiles, and book and pay for a first lesson.
Tutors set weekly availability.
The platform takes 12% commission via Stripe Connect.
## Stack
- Next.js App Router, TypeScript strict, Server Actions
- Supabase: Postgres, Auth, Storage, RLS
- Stripe Connect Express
- Vitest for unit tests, Playwright for e2e
- Supabase CLI for local dev
## Commands
- `npm run dev`: start the app
- `npm run typecheck`
- `npm run lint`
- `npm run test`: Vitest
- `npm run test:e2e`: Playwright
- `npm run test:db`: pgTAP RLS tests
- `npx supabase start`: local Supabase (needs Docker)
- `npx supabase db reset`: local only, never against a remote project
- `npx supabase migration new <name>`
- `npx supabase gen types typescript --local > src/lib/database.types.ts`
- `stripe listen --forward-to localhost:3000/api/stripe/webhook`
## Directory map
- `src/app`: routes
- `src/server`: domain logic in `booking`, `payments`, `messaging`,
`search` and `gdpr`
- `src/server/admin`: admin-only code, may use the service role key
- `supabase/migrations`: SQL migrations
- `supabase/tests`: pgTAP tests
- `src/lib/stripe.ts`: the only Stripe client
## Workflow
1. Read SPEC.md.
2. Work the current phase in PLAN.md.
3. Run the `verify` skill before saying done.
4. Use `/clear` between phases.
## Invariants
### Money
- Store money in integer minor units (pence or cents)
with a currency column.
- Commission = round(amount * 0.12), computed in ONE function,
`platformFee()`, and passed as `application_fee_amount`.
- Never use floats for money.
### Accounts and minors
- The account owner is always an adult parent.
- Students are `student_profiles` rows owned by a parent.
They have no auth login.
- Never collect a student's email or phone.
- Store the minimum student data: first name, grade and subjects.
### Tutor visibility and ID documents
- A tutor profile is public only when `verification_status = 'approved'`.
This is enforced in RLS, not only in the UI.
- ID documents live in the private bucket `id-documents`,
readable only by the owner and admins.
- ID documents are deleted 30 days after a decision.
### Messaging
- Contact details stay hidden until the parent and tutor have
at least one booking with `payment_status = 'paid'`.
- Masking runs server-side in `src/server/messaging/mask.ts`
before insert.
- It covers emails, phone numbers (including spaced or written-out
digits), and URLs or handles.
- Never mask only on render.
### Cancellation
- More than 24h before the start: full refund to the parent,
no fee kept.
- Within 24h: the lesson is charged in full.
- Tutor cancellation: always a full refund.
- The rule lives in `src/server/booking/cancellation.ts`, with tests
at the boundary (24h exactly, DST change, timezone).
### Time and booking
- Store times as `timestamptz` in UTC.
- Availability is stored as weekly rules in the tutor's IANA timezone
and expanded to slots server-side.
- Double-booking is prevented with a Postgres exclusion constraint,
not app checks.
### Stripe
- Express connected accounts.
- Destination charges with `transfer_data.destination`
and `on_behalf_of`.
- Payout schedule is weekly, set on the connected account.
- Webhooks are the source of truth for payment state.
- Verify the webhook signature.
- Handlers are idempotent, keyed on `event.id`
in a `stripe_events` table.
### Search
- Postgres only for now, no map.
- Postcode outward-code or prefix match plus subject and grade filters.
- Index with GIN and btree.
- Keep p95 under 200ms on 10k seeded tutors.
- No PostGIS until a later phase decides on it.
### Database security
- Every new table gets RLS enabled in the same migration
plus a pgTAP test in `supabase/tests`.
- Never use the service role key in client components.
Use it only in `src/server/admin` and in webhook routes.
### Secrets
- Secrets come from `.env.local` only:
`NEXT_PUBLIC_SUPABASE_URL`, `NEXT_PUBLIC_SUPABASE_ANON_KEY`,
`SUPABASE_SERVICE_ROLE_KEY`, `STRIPE_SECRET_KEY`,
`STRIPE_WEBHOOK_SECRET`.
- Never print or commit them.
## Gotchas
- Run `supabase gen types` after every migration.
- Next.js caches fetches by default.
Mark authenticated pages dynamic.
- Stripe test clocks are needed to test weekly payouts
and 24h windows.
## Review checklist
@REVIEW.md
.claude/agents/privacy-security-reviewer.md---
name: privacy-security-reviewer
description: Reviews diffs touching migrations, payments, messaging, auth or personal data for RLS gaps, money bugs, PII leaks and minors protection
model: opus
effort: high
tools: Read, Grep, Glob, Bash(git diff*)
maxTurns: 25
---
You review changes to the tutor marketplace for privacy, security and money bugs.
You make no edits. You read the diff (`git diff`, or the range you were given)
and the files it touches.
## Checklist
Apply every item in `REVIEW.md` first. That covers RLS, the service role key,
money, Stripe, booking, messaging, minors and GDPR, tutor visibility and tests.
## Attack attempts
For each of these, trace the code and the RLS policies.
Say whether the change makes it possible:
- A parent reads, updates or deletes another parent's `student_profiles` row.
- Anon or a parent sees a tutor whose `verification_status` is not `approved`.
Check direct queries, views, search and storage.
- A parent or tutor gets the other's email, phone, URL or handle before the first booking with
`payment_status = 'paid'`. This includes masking skipped on insert or applied only on render.
- A client changes a price, fee or currency. Amounts must come from the tutor rate and slot
on the server, and the fee must come only from `platformFee()`.
Also flag these: service role use outside `src/server/admin` or the webhook routes,
student PII fields with no SPEC.md data inventory update, PII in logs,
long-lived signed URLs, and webhook handlers that are not idempotent on `event.id`.
## Output
Rank findings by severity: critical, high, medium, low.
For each finding give:
- `file:line`
- what is wrong and how it could be exploited or how it fails
- a suggested fix in one or two sentences, or a short snippet
End with a one-line verdict: block, or OK to merge with fixes listed.
If you find nothing, say so and list what you checked.
.claude/agents/test-runner.md---
name: test-runner
description: Runs typecheck, lint, vitest, pgTAP, build and Playwright and returns a concise failure summary
model: haiku
effort: low
tools: Bash, Read, Grep
maxTurns: 15
---
You run the verification commands for the tutor marketplace
(Next.js, Supabase, Stripe Connect). You report the results.
You never fix anything.
## Rules
- Run only the commands you were asked to run, in the order given.
- Never edit, create or delete files.
- Never read or print `.env*` files or any secret value.
- Never run `npx supabase db push`, `npx supabase link` or any `--live` Stripe command.
- If a command needs permission and you are denied, report it as "skipped (permission)".
## Typical commands
- `npx supabase status`
- `npm run typecheck`
- `npm run lint`
- `npm run test -- --reporter=dot`. On failure, rerun only the failing file.
- `npx supabase test db`
- `npm run build`
- `npm run test:e2e -- --project=chromium <spec>`
## Output
Return at most 40 lines:
1. A table with one row per check: check, pass or fail.
2. For each failure: the failing test name, the first assertion
or error message, and `file:line`.
3. Leave out passing test output, stack traces beyond the first frame,
and full logs.
.claude/settings.json{
"$schema": "https://json.schemastore.org/claude-code-settings.json",
"permissions": {
"allow": [
"Bash(npm run typecheck)",
"Bash(npm run lint)",
"Bash(npm run test*)",
"Bash(npm run bench:search)",
"Bash(npx supabase start)",
"Bash(npx supabase status)",
"Bash(npx supabase migration new *)",
"Bash(npx supabase gen types *)",
"Bash(npx supabase test db*)",
"Bash(stripe trigger *)",
"Bash(git status)",
"Bash(git diff*)",
"Bash(git log*)"
],
"ask": [
"Bash(npx supabase db reset*)",
"Bash(npx supabase db push*)",
"Bash(npx supabase link*)",
"Bash(git push*)",
"Bash(npm install *)",
"Bash(stripe * --live*)"
],
"deny": [
"Read(./.env*)",
"Bash(rm -rf *)",
"Bash(git push --force*)",
"Bash(git reset --hard*)",
"Bash(*SUPABASE_SERVICE_ROLE_KEY*)"
],
"blockWritesOutsideWorkingDirectories": true
},
"env": {
"CLAUDE_CODE_SUBAGENT_MODEL": "haiku"
},
"hooks": {
"PostToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{
"type": "command",
"command": "f=$(jq -r '.tool_input.file_path // empty'); case \"$f\" in ''|*.sql) exit 0 ;; *.ts|*.tsx|*.js|*.jsx|*.json|*.css|*.md) npx prettier --write \"$f\" >/dev/null 2>&1 || true ;; esac"
}
]
}
]
}
}
.claude/skills/stripe-connect/SKILL.md---
name: stripe-connect
description: Use for stripe connect in this project.
---
---
name: stripe-connect
description: Stripe Connect patterns for bookings: destination charges, 12% fee, refunds, weekly payouts, webhooks
paths:
- src/server/payments/**
- src/app/api/stripe/**
---
# Stripe Connect
The only Stripe client is `src/lib/stripe.ts`. The only fee
logic is `platformFee()` in `src/server/payments/fee.ts`.
Amounts are integer minor units, computed server-side from the
tutor's `hourly_rate_minor` and the slot duration. Never trust an
amount from the client.
## Account creation
```ts
const account = await stripe.accounts.create({
type: 'express',
country: tutor.country,
email: tutor.email,
capabilities: { card_payments: { requested: true }, transfers: { requested: true } },
settings: {
payouts: { schedule: { interval: 'weekly', weekly_anchor: 'monday' } },
},
metadata: { tutor_id: tutor.id },
});
```
Send the tutor to an Account Link for onboarding. Store
`stripe_account_id` and update readiness only from `account.updated`.
## Charge
```ts
const amount = lessonPriceMinor(tutor.hourly_rate_minor, slot);
const pi = await stripe.paymentIntents.create(
{
amount,
currency: tutor.currency,
application_fee_amount: platformFee(amount),
transfer_data: { destination: tutor.stripe_account_id },
on_behalf_of: tutor.stripe_account_id,
metadata: { booking_id: booking.id },
},
{ idempotencyKey: `booking:${booking.id}:pi` },
);
```
The booking stays `pending` until the webhook says otherwise.
## Refunds
Decide eligibility only in `src/server/booking/cancellation.ts`.
Full refunds (parent cancels more than 24h before, or tutor cancels):
```ts
await stripe.refunds.create(
{
payment_intent: booking.payment_intent_id,
refund_application_fee: true,
reverse_transfer: true,
},
{ idempotencyKey: `booking:${booking.id}:refund` },
);
```
Within 24h of the start a parent cancellation is charged in full:
no refund call.
## Webhooks
Route: `src/app/api/stripe/webhook/route.ts`. Read the raw body,
verify with `stripe.webhooks.constructEvent(body, sig,
STRIPE_WEBHOOK_SECRET)`, reject on failure.
Idempotency: insert `event.id` into `stripe_events` first
(`on conflict do nothing`). If no row was inserted, return 200
and skip. Run the handler and the insert in one transaction.
Handle:
- `payment_intent.succeeded`: mark booking `paid`.
- `payment_intent.payment_failed`: mark `failed`, release slot.
- `charge.refunded`: mark `refunded`.
- `charge.dispute.created`: flag booking, notify admin.
- `account.updated`: update tutor payout readiness.
- `payout.failed`: flag tutor, notify admin.
Use the service role only in this route and `src/server/admin`.
## Testing
- `stripe listen --forward-to localhost:3000/api/stripe/webhook`
- `stripe trigger payment_intent.succeeded` (send twice; expect
one paid transition).
- Use test clocks for weekly payouts and the 24h window,
including the exact 24h boundary and a DST change.
- Never use live keys. Placeholder: `sk_test_YOUR_KEY`.
- Never print `STRIPE_SECRET_KEY` or `STRIPE_WEBHOOK_SECRET`.
.claude/skills/supabase-migration/SKILL.md---
name: supabase-migration
description: Create a Supabase migration with RLS policies and pgTAP tests
paths:
- supabase/**
---
# Supabase migration
Use this for every new table, column, policy, function or index.
## Steps
1. Create the file:
```bash
npx supabase migration new <name>
```
2. Write the SQL in the new file under `supabase/migrations`:
- `create table` with `timestamptz` for times and integer
minor units plus a `currency` column for money.
- `alter table ... enable row level security;` in the same
migration. No exceptions.
- One policy per action and role: parent, tutor, admin.
Admin checks call `public.is_admin()`.
- Indexes for every FK and every search filter column.
- FKs with `on delete` that matches GDPR deletion:
`cascade` for data owned by the user (student_profiles,
messages they own), `set null` where the record must
survive anonymised (reviews), `restrict` for financial
records kept for retention (bookings, payments).
- Never `using (true)` on a table with personal data.
- New student fields need a SPEC.md data inventory update first.
3. Add a pgTAP test in `supabase/tests/<table>_rls.test.sql`
(template below).
4. Regenerate types:
```bash
npx supabase gen types typescript --local > src/lib/database.types.ts
```
5. Run the `verify` skill (it resets the local DB and runs pgTAP).
## Policy example
```sql
alter table public.student_profiles enable row level security;
create policy "parent selects own students"
on public.student_profiles for select to authenticated
using (parent_id = (select auth.uid()) or public.is_admin());
create policy "parent inserts own students"
on public.student_profiles for insert to authenticated
with check (parent_id = (select auth.uid()));
```
## pgTAP template
```sql
begin;
select plan(5);
-- fixtures created as postgres: parent_a, parent_b, tutor_t, admin_x
-- and one student owned by parent_a
-- anon
set local role anon;
select is_empty($$ select * from public.student_profiles $$,
'anon sees no students');
-- parent A (owner)
set local role authenticated;
set local request.jwt.claims = '{"sub":"+00 000 000 000-00000000000a","role":"authenticated"}';
select isnt_empty($$ select * from public.student_profiles $$,
'parent A sees own student');
-- parent B (other parent)
set local request.jwt.claims = '{"sub":"+00 000 000 000-00000000000b","role":"authenticated"}';
select is_empty($$ select * from public.student_profiles $$,
'parent B cannot see parent A students');
-- tutor
set local request.jwt.claims = '{"sub":"+00 000 000 000-00000000000c","role":"authenticated"}';
select is_empty($$ select * from public.student_profiles $$,
'tutor cannot list students');
-- admin
set local request.jwt.claims = '{"sub":"+00 000 000 000-00000000000d","role":"authenticated"}';
select isnt_empty($$ select * from public.student_profiles $$,
'admin sees students');
select * from finish();
rollback;
```
Also test update and delete for the other parent
(`throws_ok` or a row count of 0).
## Rules
- Never edit an applied migration. Add a new one.
- Every `security definer` function has `set search_path = ''`
and fully qualified names (`public.profiles`).
- Never use the service role in tests to prove access; impersonate.
- Never run `supabase db push` or `supabase link` from here.
.claude/skills/verify/SKILL.md---
name: verify
description: Run typecheck, lint, unit, RLS and e2e checks for the tutor marketplace and report only failures
when_to_use: before claiming any task done, after migrations, after payment or booking changes
---
# Verify
Run these steps in order. Stop at the first failing step, fix it,
then restart from that step. Never say done while any check fails.
## Delegation
Run steps 0 to 2 yourself. Delegate steps 3 to 6 to the
`test-runner` subagent so long logs stay out of the main context.
Tell it which steps apply (for example "steps 3, 4 and 6;
e2e spec tests/e2e/booking.spec.ts") and ask for failures only.
## Step 0: local Supabase is up
```bash
npx supabase status
```
If it reports that it is not running, start it:
```bash
npx supabase start
```
## Step 1: migrations changed
Only if anything under `supabase/migrations` changed since the
last run:
```bash
npx supabase db reset
npx supabase gen types typescript --local > src/lib/database.types.ts
```
`db reset` is local only and asks permission. Never run
`supabase db push` or touch a remote project here.
## Step 2: static checks
```bash
npm run typecheck
npm run lint
```
## Step 3: unit tests (Vitest)
```bash
npm run test -- --reporter=dot
```
On failure, rerun only the failing file to get a readable error:
```bash
npm run test -- src/server/booking/cancellation.test.ts
```
## Step 4: RLS tests (pgTAP)
```bash
npx supabase test db
```
## Step 5: payments and webhooks
Only if `src/server/payments`, `src/app/api/stripe` or booking
payment state changed. Ask the user to keep this running in a
separate terminal:
```bash
stripe listen --forward-to localhost:3000/api/stripe/webhook
```
Then send the event twice for a test booking:
```bash
stripe trigger payment_intent.succeeded \
--add payment_intent:metadata[booking_id]=<booking_id>
```
To test dedupe on one event id, also resend the same event
(this asks permission): `stripe events resend <evt_id>`.
Confirm with SQL against the local database:
```sql
select count(*) from stripe_events where id = '<evt_id>';
-- expect 1
select payment_status, updated_at from bookings where id = '<booking_id>';
-- expect 'paid', updated once
```
## Step 6: build and e2e
```bash
npm run build
npm run test:e2e -- --project=chromium
```
Pass the affected spec files to limit the run when possible.
## Report format
| Check | Result | First failing assertion |
|-------|--------|-------------------------|
| supabase status | pass | |
| typecheck | pass | |
| lint | pass | |
| vitest | fail | cancellation.test.ts:42 expected refund 0 |
| pgTAP | pass | |
| stripe webhook | skipped | not changed |
| build | pass | |
| e2e (chromium) | pass | |
Mark steps that did not apply as skipped with a reason.
If any row is fail, say so plainly and list the next fix.
INSTALL.md# Install and setup
## Prerequisites
- Node 20 or newer.
- Docker (local Supabase runs in containers).
- Supabase CLI: `npm i -D supabase`.
- Stripe CLI, then run `stripe login`.
- Claude Code 2.1.296 or newer. Check with `claude --version`.
## Supabase
- Develop fully local: `npx supabase start`.
- Create the hosted Supabase project in an EU region (GDPR).
- Do not link or push to it from Claude sessions. Those commands ask first.
## Stripe
- In the Stripe test dashboard, enable Connect with the Express account type.
- Forward webhooks locally:
`stripe listen --forward-to localhost:3000/api/stripe/webhook`
- Use test keys only.
## Environment
Create `.env.local` with placeholders, then fill it with test values yourself:
```
NEXT_PUBLIC_SUPABASE_URL=YOUR_SUPABASE_URL
NEXT_PUBLIC_SUPABASE_ANON_KEY=YOUR_SUPABASE_ANON_KEY
SUPABASE_SERVICE_ROLE_KEY=YOUR_SERVICE_ROLE_KEY
STRIPE_SECRET_KEY=sk_test_YOUR_KEY
STRIPE_WEBHOOK_SECRET=whsec_YOUR_SECRET
```
Never commit it. Claude is denied from reading `.env*` files in
`.claude/settings.json`, so paste no secrets into the chat.
## Plugins
```
claude plugin install code-review@claude-plugins-official
claude plugin install security-guidance@claude-plugins-official
claude plugin install commit-commands@claude-plugins-official
claude plugin marketplace add anthropics/skills
claude plugin install webapp-testing@anthropic-agent-skills
```
## How to work
1. Edit the open questions in SPEC.md (launch countries, currency) first.
2. Start `claude` in the repo root and open PLAN.md.
3. Paste the current phase's `/goal` prompt. Run one phase per session.
4. `/goal clear` stops a running goal.
5. `/clear` between phases.
6. `/usage` shows where tokens went.
## Model and effort
- No model override is set, so the default Opus 5.5 at medium effort applies.
- For Phase 4 (payments) and Phase 6 (GDPR and hardening), raise effort
with `/effort high` rather than switching models.
Changing effort keeps the prompt cache; switching models breaks it.
- Subagents without their own model default to haiku via `env` in settings.
## Local overrides
Put personal permissions or hooks in `.claude/settings.local.json`.
It is not committed. Example:
```json
{
"permissions": {
"allow": ["Bash(npm run dev)"]
},
"hooks": {
"Stop": [
{
"hooks": [
{ "type": "command", "command": "say done" }
]
}
]
}
}
```
Make sure `.claude/settings.local.json` is listed in `.gitignore`.
PLAN.md# Build plan
Run one phase per session. `/clear` between phases, then paste that phase's
/goal prompt. Each goal prompt is under 3,500 chars and states the end state,
the check, constraints and a turn bound. `/goal clear` stops a run early.
Use `/effort high` for Phases 4 and 6. Read SPEC.md before each phase.
## Phase 1: Foundation
Next.js plus local Supabase, parent and tutor auth, `student_profiles`, role
column, RLS on everything, pgTAP and Vitest wiring, verify skill working.
This is the kickoff prompt.
Files: `package.json`, `src/app/**`, `src/lib/supabase/*`, `src/server/payments/fee.ts`,
`supabase/migrations/*`, `supabase/tests/*`, `e2e/*`.
Verify: `npm run typecheck`, `npm run lint`, `npm run test`, `npx supabase test db`,
`npm run build`, `npm run test:e2e`.
```
/goal Phase 1 Foundation for the tutor marketplace (read CLAUDE.md and SPEC.md first; explore, then plan, then code).
End state:
1. Next.js App Router, TypeScript strict, ESLint, Prettier, Vitest, Playwright. npm scripts: typecheck, lint, test, test:e2e, test:db.
2. Local Supabase runs. Migrations create: profiles (id = auth.users.id, role enum parent|tutor|admin, full_name, country, created_at), student_profiles (id, parent_id FK profiles on delete cascade, first_name, grade, subjects text[]; no email or phone), tutor_profiles (id FK profiles, bio, hourly_rate_minor int, currency char(3), postcode_outward text, verification_status enum pending|approved|rejected default pending), subjects and grades lookups, audit_log, and is_admin() security definer with set search_path = ''.
3. RLS on every table. Parents CRUD only their own student_profiles. tutor_profiles selectable by anon and parents only when approved, always by owner and admins. audit_log admin-only.
4. Email sign-up asks role parent or tutor and an 18+ checkbox stored with a timestamp. A trigger creates the profiles row. Parent dashboard adds, edits and deletes students.
5. platformFee(amountMinor) in src/server/payments/fee.ts returns Math.round(amount*0.12) on integers, with Vitest tests (0, 1, 999, 2500, large values).
6. Types generated to src/lib/database.types.ts.
Checks (verify skill, delegated to test-runner; show the summary in the conversation):
- typecheck and lint clean; npm run test passes incl. fee tests.
- npx supabase test db proves: parent A cannot select, update or delete parent B's students; anon cannot see a pending tutor; anon can see an approved tutor; non-admin cannot read audit_log.
- npm run build succeeds.
- Playwright: a parent signs up, adds a student and sees it listed.
Constraints: never read or print .env files (.env.example with YOUR_* values only). No service role key needed. No Stripe calls, search, booking or messaging. No supabase db push or remote project. Simple accessible components. Ask before adding dependencies outside next, react, @supabase/supabase-js, @supabase/ssr, zod, vitest, @playwright/test, prettier, eslint.
Stop when all checks are shown passing, or after 40 turns. If bounded out, list remaining failures and next steps under Phase 1 notes in PLAN.md.
```
## Phase 2: Tutor onboarding and verification
Profile editor, subjects and grades taxonomy, private `id-documents` bucket,
admin queue, audit_log, RLS visibility on `verification_status`, Stripe Express link.
Files: `src/app/(tutor)/onboarding/**`, `src/app/admin/**`, `src/server/admin/*`,
`src/server/payments/connect.ts`, `src/app/api/stripe/webhook/route.ts`,
`supabase/migrations/*`, `supabase/tests/*`, `e2e/onboarding.spec.ts`.
Verify: verify skill steps 0 to 6, plus `stripe trigger account.updated`.
```
/goal Phase 2 tutor onboarding and verification (read CLAUDE.md, SPEC.md tutor onboarding and admin console).
End state:
1. Tutor profile editor: bio, subjects and grades from the taxonomy, hourly rate in minor units with currency, postcode outward code.
2. Private storage bucket id-documents with policies: owner and admins read, owner uploads. Admin viewer uses signed URLs with 5 min expiry.
3. Admin queue lists pending tutors; approve and reject (reject needs a reason). Every decision writes audit_log. Rejected tutors see the reason and can resubmit (status back to pending).
4. Public visibility gated by RLS on verification_status = 'approved'.
5. Stripe Express onboarding link from src/server/payments/connect.ts via src/lib/stripe.ts. Webhook verifies the signature, stores event.id in stripe_events, and handles account.updated to store charges_enabled and payouts_enabled.
6. Retention job design for deleting ID docs 30 days after decision (implementation may land in Phase 6).
Checks (show results in the conversation):
- pgTAP proves an unapproved tutor is invisible to anon and to parents, and storage policies block other users from id-documents.
- Playwright covers upload, then admin approval, then the profile being visible to anon.
- typecheck, lint, vitest, build green via the verify skill.
Constraints: service role key only in src/server/admin and webhook routes with import 'server-only'. Test keys only. No search, booking or messaging. No remote Supabase changes.
Stop when all checks are shown passing, or after 40 turns. If bounded out, record remaining failures under Phase 2 notes.
```
## Phase 3: Search and profiles
GIN and btree indexes, postcode normalisation, 10k-tutor seed, search Server
Action with pagination, profile page with rating aggregates.
Files: `src/server/search/*`, `src/app/search/**`, `src/app/tutors/[id]/**`,
`supabase/migrations/*`, `supabase/seed/*`, `scripts/bench-search.ts`.
Verify: verify skill, `npm run bench:search`, `EXPLAIN ANALYZE` on the search query.
```
/goal Phase 3 search and tutor profiles (read CLAUDE.md search section and SPEC.md search and profile stories).
End state:
1. normalise_postcode_outward(text) SQL function with Vitest or pgTAP cases (spacing, case, partial codes).
2. GIN indexes on subjects and grades (arrays or tsvector), btree on postcode_outward, hourly_rate_minor and rating.
3. Seed script for 10k approved and pending tutors with availability and ratings.
4. Search Server Action: subject required, grade and postcode optional, sort by rating, price or soonest availability, keyset or offset pagination, approved tutors only.
5. Profile page: bio, subjects, grades, rate, average rating and review count (materialized view or trigger-maintained columns), next available slots placeholder.
6. npm run bench:search script measuring p95 over 200 varied queries.
Checks (show output in the conversation):
- npm run bench:search shows p95 under 200ms locally.
- EXPLAIN ANALYZE of the main query shows index use, no seq scan on tutor_profiles.
- pgTAP: search never returns unapproved tutors.
- Full verify skill green.
Constraints: Postgres only, no PostGIS, no external search service, no map. Mark authenticated pages dynamic. No booking or payments.
Stop when checks are shown passing, or after 35 turns. If bounded out, record remaining failures under Phase 3 notes.
```
## Phase 4: Availability, booking and payments
Run with `/effort high`. Weekly rules plus exceptions, slot expansion,
exclusion constraint, destination charges, idempotent webhooks, cancellation.
Files: `src/server/booking/*` (incl. `cancellation.ts`), `src/server/payments/*`,
`src/app/api/stripe/webhook/route.ts`, `src/app/book/**`, `supabase/migrations/*`, `supabase/tests/*`.
Verify: verify skill incl. step 5 (Stripe triggers), Stripe test clocks for 24h and payouts.
```
/goal Phase 4 availability, booking and payments (read CLAUDE.md invariants and the stripe-connect skill).
End state:
1. availability_rules (weekday, start, end, tutor IANA timezone) and availability_exceptions. Server-side slot expansion in the tutor's timezone, returned in UTC.
2. bookings table: tutor_id, parent_id, student_id, tstzrange, amount_minor, currency, payment_status, with an exclusion constraint (btree_gist) preventing overlapping ranges per tutor.
3. Booking Server Action computes amount from tutor rate x duration server-side. PaymentIntent destination charge with transfer_data.destination, on_behalf_of, application_fee_amount from platformFee(), metadata booking_id, idempotency key booking:<id>:pi.
4. Webhooks: payment_intent.succeeded, payment_intent.payment_failed, charge.refunded, charge.dispute.created, payout.failed. Insert event.id into stripe_events first; skip duplicates.
5. cancellation.ts: >24h full refund (refund_application_fee, reverse_transfer), within 24h charged in full, tutor cancel always full refund.
6. Connected accounts use a weekly payout schedule anchored Monday.
Checks (show output in the conversation):
- Vitest boundary tests for exactly 24h, DST change and a non-UK timezone pass.
- A concurrent double-booking test fails at the database with the exclusion constraint error.
- stripe trigger payment_intent.succeeded sent twice marks the booking paid exactly once (SQL count shown).
- Full verify skill green.
Constraints: integer minor units only, fee only via platformFee(), cancellation logic only in cancellation.ts. Test keys only. Never trust client amounts. No messaging or reviews.
Stop when checks are shown passing, or after 50 turns. If bounded out, record remaining failures under Phase 4 notes.
```
## Phase 5: Messaging and reviews
Threads, server-side masking, unmask after first paid booking, Realtime
under RLS, report flag, review requests at lesson end + 1h.
Files: `src/server/messaging/*` (incl. `mask.ts`), `src/server/reviews/*`,
`src/app/messages/**`, `src/app/api/cron/*`, `supabase/migrations/*`, `supabase/tests/*`.
Verify: verify skill, masking corpus test file.
```
/goal Phase 5 messaging and reviews (read CLAUDE.md messaging invariant and SPEC.md messaging and reviews).
End state:
1. threads (parent, tutor, student) and messages tables with RLS for participants and admins only.
2. src/server/messaging/mask.ts masks emails, phone numbers (spaced, dotted, written-out digits), URLs and social handles before insert, unless the pair has a booking with payment_status = 'paid' (checked server-side).
3. Test corpus of 30+ evasion strings plus false-positive cases (prices, times, grades).
4. Supabase Realtime subscription on messages respecting RLS.
5. Report action creates a flag visible in the admin flagged-messages view. Notification emails contain no message body.
6. Review requests scheduled via pg_cron or a cron route at lesson end + 1h. reviews table: one per booking per side, rating 1-5, text, editable 7 days; tutor feedback private.
Checks (show output in the conversation):
- The masking corpus passes in Vitest.
- pgTAP proves non-participants cannot read threads or messages.
- pgTAP or Vitest proves a review cannot be created for an unpaid or uncompleted booking, nor twice per side.
- Full verify skill green.
Constraints: never mask only on render. No PII in logs. No changes to payment logic. Rate limits wait for Phase 6.
Stop when checks are shown passing, or after 40 turns. If bounded out, record remaining failures under Phase 5 notes.
```
## Phase 6: GDPR and hardening
Run with `/effort high`. Export, deletion, retention jobs, consent records,
privacy page, rate limits, security headers, full smoke test.
Files: `src/server/gdpr/*`, `src/app/account/**`, `src/app/privacy/**`, `middleware.ts`,
`next.config.ts`, `supabase/migrations/*`, `scripts/check-no-pii.sql`, `e2e/smoke.spec.ts`.
Verify: full verify skill, `scripts/check-no-pii.sql`, then the privacy-security-reviewer agent.
```
/goal Phase 6 GDPR and hardening (read SPEC.md GDPR section and data inventory).
End state:
1. Data export endpoint returns JSON covering every table in the SPEC.md data inventory for the signed-in user.
2. Deletion flow: removes PII, anonymises reviews, keeps financial records and Stripe references per retention, deletes students and ID documents.
3. Retention jobs: ID docs 30 days after decision, messages 2 years after last booking.
4. consent_records table; marketing consent off by default with timestamps.
5. Privacy page listing sub-processors (Supabase, Stripe, email provider). Cookie banner only if non-essential cookies exist.
6. Rate limits on messaging and search; security headers (CSP, HSTS, frame-ancestors, referrer policy).
7. Playwright smoke test over sign-up, search, profile, booking with test payment, messaging, review and export.
Checks (show output in the conversation):
- Export for a seeded test parent contains every inventory table.
- After deletion, scripts/check-no-pii.sql finds no PII for that user in any table.
- Full verify skill green, including the smoke test.
Constraints: no remote Supabase changes, test Stripe keys only, no PII in logs. Do not weaken existing RLS.
Stop when checks are shown passing, or after 45 turns. If bounded out, record remaining failures under Phase 6 notes.
```
## Phase notes
Record bounded-out failures and next steps here, per phase.
REVIEW.md# Review checklist
Imported by CLAUDE.md so `/code-review` applies it.
Block on the first nine sections. Only flag the last section.
## RLS
- Every new table has `enable row level security`, policies and a pgTAP test in `supabase/tests`.
- No `using (true)` on tables with personal data.
- `security definer` functions set `search_path = ''`.
## Service role key
- `SUPABASE_SERVICE_ROLE_KEY` appears only in server-only modules that start with `import 'server-only'`.
- Allowed only in `src/server/admin` and webhook routes. Never in client components.
## Money
- Amounts are integer minor units (pence or cents). No floats anywhere.
- A currency column or field always travels with the amount.
- The fee comes only from `platformFee()`. No inline `* 0.12`.
## Stripe
- The webhook verifies its signature with `STRIPE_WEBHOOK_SECRET`.
- Handlers are idempotent: insert `event.id` into `stripe_events` first and skip duplicates.
- No client-sent amounts are trusted. The amount is computed server-side from the tutor rate and the slot.
## Booking
- Overlap is prevented by the DB exclusion constraint, not by app checks.
- `timestamptz` everywhere, stored in UTC.
- Cancellation and refund logic lives only in `src/server/booking/cancellation.ts`.
## Messaging
- Masking runs in `src/server/messaging/mask.ts` before insert, never only on render.
- The unmask check queries paid bookings server-side.
## Minors and GDPR
- No new student PII fields without a data inventory update in SPEC.md.
- No student email or phone, ever.
- No PII in logs, errors or analytics events.
- Signed URLs are short-lived (5 min for ID documents).
## Tutor visibility
- Any query returning tutors respects `verification_status = 'approved'` for anon and parents.
## Tests
- New domain logic has Vitest tests, including boundary cases.
- User-facing flow changes update the Playwright specs.
## Flag but don't block
- Missing loading or error states.
- Missing accessibility labels on form fields.
SPEC.md# Product spec: local tutor marketplace
Source of truth for scope and acceptance criteria. Edit this file first,
then update PLAN.md. Claude reads it at the start of every phase.
## Purpose
Parents find local tutors by subject, grade and postcode, view profiles,
and book and pay for a first lesson. Tutors set weekly availability.
The platform takes 12% of each lesson via Stripe Connect.
## Roles
- **Parent**: adult account owner (18+). Manages students, books, pays and messages.
- **Student**: child profile owned by a parent. No login, no contact data.
- **Tutor**: offers lessons, sets rate and availability, gets paid out weekly.
- **Admin**: verifies tutors, reviews flagged messages, handles disputes.
## User stories
### Search
As a parent, I search for tutors by subject, grade and postcode.
- Filters: subject (required), grade (optional), postcode (outward code or prefix).
- Sort by rating, price, or soonest availability.
- Only tutors with `verification_status = 'approved'` appear.
- Results paginate and show name, subjects, hourly rate, rating and next slot.
- An empty result suggests widening the postcode or removing the grade.
### Profile page
As a parent, I view a tutor's profile before booking.
- Shows bio, subjects, grades, hourly rate, average rating, review count
and the next available slots in the parent's timezone.
- An unapproved profile returns 404 to anyone except its owner and admins.
### Book a first lesson
As a parent, I book a first lesson for one of my students.
- Pick a student, a subject and an open slot.
- Price = tutor hourly rate × duration, shown before payment with currency.
- A slot taken by someone else cannot be booked; the database rejects overlaps.
- The booking is `pending` until payment succeeds.
### Payment
As a parent, I pay by card at booking time.
- The amount is computed server-side; client-sent amounts are ignored.
- The booking becomes `paid` only from the Stripe webhook.
- A failed payment releases the slot and shows a retry option.
- Parent and tutor both get a confirmation email.
## Tutor onboarding
1. Profile: name, bio, photo (optional), country, postcode.
2. Subjects and grades from the fixed taxonomy.
3. Hourly rate in the currency of the tutor's country.
4. Weekly availability rules plus date exceptions (holidays, one-off slots).
5. Stripe Express onboarding.
6. ID document upload to the private bucket.
- The profile stays hidden until an admin approves it.
- Rejection includes a reason shown to the tutor; the tutor can fix and resubmit.
## Admin console
- Verification queue sorted by submission time.
- Document viewer using a signed URL with a 5 minute expiry.
- Approve and reject actions; reject requires a reason.
- Every decision writes an `audit_log` entry (admin, action, target, reason, time).
- Flagged-messages view with the thread context and a resolve action.
## Messaging
- One thread per parent, tutor and student.
- Contact info (emails, phone numbers, URLs, handles) is masked server-side
until the pair has at least one booking with `payment_status = 'paid'`.
- Users can report a message; reports appear in the admin flagged view.
- New-message emails contain no message body, only a link.
## Payments
- Platform commission is 12%, computed by `platformFee()` in minor units.
- Lesson price = tutor hourly rate × duration.
- Payouts go out weekly on a fixed weekday (Monday).
- Cancellation: more than 24h before start, a full refund and no fee kept.
Within 24h, the lesson is charged in full. Tutor cancellation is always a full refund.
- Disputes: mark the booking disputed, notify admin, keep evidence in the audit log.
- Failed payments: release the slot and notify the parent.
## Reviews
- At lesson end + 1h, both sides get a review request.
- The parent reviews the tutor (public). The tutor leaves private feedback for the parent.
- Rating 1 to 5 plus text.
- Allowed only for a completed, paid lesson. One review per booking per side.
- Editable for 7 days after submission.
## GDPR and minors
### Data inventory
| Data | Owner | Lawful basis | Retention |
|------|-------|--------------|-----------|
| Parent account (name, email) | Parent | Contract | Until deletion |
| Student profile (first name, grade, subjects) | Parent | Contract | Until deletion |
| Tutor profile and rate | Tutor | Contract | Until deletion |
| ID documents | Tutor | Legal obligation / legitimate interest | 30 days after decision |
| Messages | Both | Contract | 2 years after last booking |
| Bookings and payments | Both | Legal obligation (accounting) | Statutory period |
| Reviews | Author | Legitimate interest | Anonymised on deletion |
| Marketing consent | User | Consent | Until withdrawn |
| Audit log | Admin | Legitimate interest | Statutory period |
Add a row here before adding any new personal data field.
### Rights and controls
- Marketing consent is off by default and stored with a timestamp.
- Data export as JSON covering every table in the inventory.
- Account deletion removes PII; reviews are anonymised. Stripe records and
financial records are kept under the retention exceptions.
- Cookie banner only if non-essential cookies are used.
- Hosted Supabase project in an EU region.
- Sub-processors: Supabase, Stripe, email provider (listed on the privacy page).
### Minors
- Parent-only accounts with an 18+ confirmation at sign-up.
- No student email, phone or other contact data.
- No public student information anywhere.
## Non-functional requirements
- Search p95 under 200ms on 10k seeded tutors.
- WCAG AA on core flows (search, profile, booking, payment, messaging).
- en-GB first, with i18n-ready strings.
- Currency per tutor country, stored in integer minor units.
## Out of scope for v1
- Map search
- Group lessons
- Subscriptions
- Native apps
- In-app video
## Open questions
- Launch countries and currencies?
- Refund policy on tutor no-show?
- Minimum lesson length (30, 45 or 60 minutes)?
- Must a parent leave a review before the next booking?