Language exchange video call app with matching
Expo React Native app on Supabase that matches learners across time zones for split-language video calls, with SRS flashcards and freemium quotas.
exporeact-nativetypescriptsupabasepostgresrevenuecat
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 language-exchange-video-call-app-with-matching-02898eCLAUDE.md# Language exchange app
## Purpose
Two language learners are matched for a 30 minute video call.
Each person's language gets 15 minutes.
Conversation cards suggest topics during the call.
After the call, each user rates the other.
Each pair shares a deck of spaced-repetition (SRS) flashcards.
The free tier allows 3 calls per week.
A subscription removes the limit.
See SPEC.md for product rules and PLAN.md for build phases.
## Stack
- Expo, managed workflow with a dev client.
- TypeScript in strict mode.
- expo-router for navigation.
- Supabase:
- Postgres, Auth and RLS
- Edge Functions (Deno)
- Realtime
- pg_cron
- Video sits behind an adapter in `src/lib/video/`.
- The provider is still a placeholder `VIDEO_PROVIDER`, for example LiveKit or Daily.
- App code imports the adapter, never the provider SDK directly.
- RevenueCat for subscriptions (entitlement `unlimited`).
- expo-notifications for push.
## Commands
- `npx expo start`: dev server (needs the dev client build).
- `npx tsc --noEmit`: type check.
- `npx eslint . --max-warnings 0`: lint.
- `npx jest`: unit tests.
- `supabase start`: local stack (Docker must be running).
- `supabase db reset`: rebuild the local DB from migrations and seed.
- `supabase test db`: pgTAP tests in `supabase/tests/`.
- `deno test supabase/functions`: Edge Function tests.
- `supabase gen types typescript --local > src/types/db.ts`: regenerate DB types.
## Architecture rules
### Time
- Store every time as `timestamptz` in UTC.
- Store availability as weekly rules: an IANA time zone name plus a local start and end time.
- The server expands those rules into UTC `tstzrange` slots.
- Never compute matches in the client.
- Use date-fns-tz only to display times.
### Security
- Every new table gets RLS enabled in the same migration.
- Every new table also gets a pgTAP test in `supabase/tests/`.
- Never use the service role key in the app.
### Business logic
- Business rules live in Postgres functions or Edge Functions:
- quota check
- matching
- no-show penalty
- report handling
- The client only calls these functions.
### Contact details
- Profile text fields must not contain contact details:
- emails, phone numbers, URLs, @handles
- the words whatsapp, telegram, insta
- Check for them in two places:
- a DB trigger, which is the source of truth
- the client, for faster feedback (`src/lib/contactFilter.ts`)
### Blocking
- Blocked users are excluded from matching, chat, flashcard sharing and search.
- Every such query uses one SQL helper: `is_blocked(a, b)`, which checks both directions.
### Call timer
- The server anchors the call timer.
- Each call has a `started_at` value.
- The two halves are derived from `started_at` + 15 min.
- This way both phones switch language at the same moment, even if their clocks drift.
## Gotchas
- DST changes shift weekly availability.
- Test with Europe/London and America/New_York around the March and November transitions.
- Some zones have half-hour offsets (Asia/Kolkata is +5:30).
- Regenerate the DB types after every migration.
- Push notifications need a physical device or a dev build. They do not work in a simulator or in Expo Go.
## Secrets
- Never commit `.env`.
- Use placeholder keys:
- `SUPABASE_ANON_KEY`
- `REVENUECAT_API_KEY`
- `VIDEO_API_KEY`
- `VIDEO_API_SECRET`
- `VIDEO_API_SECRET` lives only in the Edge Function environment.
- It is set with `supabase secrets set`.
- It never goes in app code or in `EXPO_PUBLIC_` variables.
## Workflow
1. Explore first, then plan, then write code.
2. Run the `verify` skill before saying a task is done.
3. Delegate long test output to the `test-runner` subagent.
4. For changes to migrations, Edge Functions, safety features or payments, run the `security-reviewer` agent before finishing.
5. Follow the `db-change` skill for migrations.
6. Follow the `timezone-matching` skill for availability and matching work.
## Review checklist
@REVIEW.md
.claude/agents/security-reviewer.md---
name: security-reviewer
description: Use before finishing phases touching migrations, Edge Functions, reports, blocks, payments; checks RLS, secrets, quota and safety rules.
model: opus
effort: high
tools: Read, Grep, Glob, Bash(supabase test db)
---
You review security and user safety for the language exchange app.
Read REVIEW.md, CLAUDE.md and the relevant parts of SPEC.md first.
Check the changed code against every item in REVIEW.md, especially:
- RLS enabled and tested on each new table; no `true` select policies on user data.
- No service role key or VIDEO_API_SECRET in app code or EXPO_PUBLIC_ vars.
- Every query touching other users uses is_blocked(a, b).
- Contact-detail trigger covers all user-visible text.
- Quota, matching and no-show logic only in Postgres functions or Edge Functions.
- Edge Functions validate JWT and input with zod; RevenueCat webhook verifies signatures.
- Security definer functions set search_path = public and check auth.uid().
- Push and video tokens never logged.
Try to break it by reading policies, functions and pgTAP tests:
- Can user C read the flashcard deck of pair A and B?
- Can a blocked user select the blocker's profile, or the reverse?
- Can a free user bypass can_schedule_call() with a direct insert into calls or offers?
- Can a client set match_priority, no_show, entitlement or admin fields itself?
Run `supabase test db` to confirm current tests pass, and note
which attack is not covered by a test.
Do not edit files. Return findings ranked by severity
(critical, high, medium, low), each with file:line, the risk,
and a concrete fix or missing test. Say clearly if nothing was found.
.claude/agents/test-runner.md---
name: test-runner
description: Runs tsc, eslint, jest, supabase test db, deno test and reports failing tests with file:line and short message.
model: haiku
tools: Bash, Read, Grep
disallowedTools: Edit, Write
maxTurns: 15
---
You run the verify commands for this Expo + Supabase app and report only what failed.
Never edit files. Never run deploys, `supabase db push` or `supabase db reset --linked`.
Run only the commands the caller asks for, from this set:
- `npx tsc --noEmit`
- `npx eslint . --max-warnings 0`
- `npx jest --silent`
- `supabase db reset`, then `supabase gen types typescript --local > src/types/db.ts`, then `supabase test db`
- `deno test --allow-env --allow-net supabase/functions`
Report format:
- One summary line per command, copied from its output (pass/fail counts).
- For each failure: `file:line`, test or rule name, one-line message.
- Group repeated errors and give a count instead of repeating them.
- No full logs and no stack traces beyond the first relevant frame.
- If a command could not run (Docker down, missing binary), say so in one line.
.claude/settings.json{
"permissions": {
"allow": [
"Bash(npx tsc --noEmit)",
"Bash(npx eslint:*)",
"Bash(npx jest:*)",
"Bash(npx prettier:*)",
"Bash(supabase start)",
"Bash(supabase status)",
"Bash(supabase db reset)",
"Bash(supabase test db)",
"Bash(supabase gen types:*)",
"Bash(supabase migration new:*)",
"Bash(deno test:*)",
"Bash(npx expo-doctor)",
"Bash(git status)",
"Bash(git diff:*)"
],
"ask": [
"Bash(supabase db push:*)",
"Bash(supabase functions deploy:*)",
"Bash(supabase secrets:*)",
"Bash(eas build:*)",
"Bash(eas submit:*)",
"Bash(npm install:*)",
"Bash(git push:*)"
],
"deny": [
"Read(./.env*)",
"Bash(rm -rf:*)",
"Bash(supabase db reset --linked:*)",
"Bash(git push --force:*)"
],
"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 *.ts|*.tsx) npx prettier --write \"$f\" >/dev/null 2>&1 ;; esac; exit 0"
}
]
}
]
}
}
.claude/skills/db-change/SKILL.md---
name: db-change
description: Use when adding or changing a Supabase migration, table, policy or SQL function, so every change ships with RLS, indexes, pgTAP tests and regenerated types.
---
# Database change
## 1. Create the migration
`supabase migration new <name>`
One concern per migration (one table, or one function).
Keep migrations additive; note how to reverse them in a comment.
## 2. Template
```sql
create table public.example (
id uuid primary key default gen_random_uuid(),
owner_id uuid not null references public.profiles(id) on delete cascade,
call_id uuid references public.calls(id) on delete cascade,
slot tstzrange,
created_at timestamptz not null default now()
);
alter table public.example enable row level security;
create policy example_owner_all on public.example
for all to authenticated
using (owner_id = auth.uid())
with check (owner_id = auth.uid());
create policy example_pair_select on public.example
for select to authenticated
using (exists (
select 1 from public.calls c
where c.id = example.call_id
and auth.uid() in (c.user_a, c.user_b)
and not public.is_blocked(c.user_a, c.user_b)
));
create index on public.example (owner_id);
create index on public.example (call_id);
create index on public.example using gist (slot);
```
Never write `using (true)` on user data.
Exclude blocked users with `is_blocked(a, b)` in any policy or query touching another user.
## 3. Security definer functions
```sql
create or replace function public.do_thing(p_id uuid)
returns void language plpgsql security definer
set search_path = public as $$
begin
if auth.uid() is null then raise exception 'not authenticated'; end if;
-- check auth.uid() owns or participates in p_id before acting
end $$;
revoke all on function public.do_thing(uuid) from public;
grant execute on function public.do_thing(uuid) to authenticated;
```
## 4. pgTAP test
File: `supabase/tests/<name>.test.sql`. Pattern with two users and a blocked third:
```sql
begin;
select plan(4);
select tests.create_supabase_user('alice');
select tests.create_supabase_user('bob');
select tests.create_supabase_user('mallory');
-- seed rows; insert block (alice blocks mallory)
select tests.authenticate_as('alice');
select isnt_empty($$ select * from public.example $$, 'owner reads own rows');
select tests.authenticate_as('mallory');
select is_empty($$ select * from public.example $$, 'blocked user reads nothing');
select tests.clear_authentication();
select * from finish();
rollback;
```
Cover: owner allowed, pair participant allowed, outsider denied, blocked user denied.
## 5. Finish
`supabase gen types typescript --local > src/types/db.ts`, then run the `verify` skill.
.claude/skills/timezone-matching/SKILL.md---
name: timezone-matching
description: Use when touching availability rules, slot expansion, find_matches() or offer ranking, to apply the time zone storage model, DST-safe expansion and required test fixtures.
---
# Time zones and matching
All scheduling happens in Postgres. Never expand slots or compute matches in the client.
The client uses date-fns-tz only to display UTC values in the user's zone.
## Storage model
- `availability_rules(user_id, tz, weekday, local_start time, local_end time)`
- `tz` is an IANA name (`Europe/London`), never an offset.
- Weekday is in the user's local zone.
- `availability_slots(user_id, slot tstzrange)` is generated, never edited by users.
- Gist index on `slot`; overlap with `&&`.
- One-off exceptions remove or add ranges before slots are written.
## Expansion
```sql
(d::date + r.local_start) at time zone r.tz -- timestamptz in UTC
```
- `d` is each local date for the next 14 days with matching weekday.
- Postgres applies the correct offset per date, so DST is handled for free.
- If `local_end <= local_start`, the window crosses midnight: end uses `d + 1`.
- Local times in a DST gap (for example 01:30 on a spring-forward night in London)
are shifted by Postgres; tests must assert the actual result.
- Re-expand nightly via pg_cron and on any rule, exception or tz change.
- Matching slots are 30-min ranges inside the overlap of both users' slots.
## Matching filter (from SPEC.md)
- A.native in B.learning and B.native in A.learning.
- Overlapping 30-min slot.
- `not is_blocked(a, b)`.
- Both under quota (`can_schedule_call()`), neither in a no-show pause.
## Ranking
1. `match_priority` desc
2. Level closeness (smaller CEFR gap first)
3. Shared interests count desc
4. Prefer pairs not matched in the last 7 days
5. Tie-breaker: oldest waiting offer first
Offers expire if both do not accept within 12 h.
## Required test fixtures (pgTAP)
- London + New York across 2026-03-08 (US change) and 2026-03-29 (EU change):
the gap is 5 h, then 4 h between the dates, then 5 h again.
- Asia/Kolkata (+5:30) matched with a whole-hour zone; slots on :30 boundaries.
- Pacific/Auckland vs America/Los_Angeles across the date line:
local weekday differs from UTC weekday.
- User changing tz: old slots removed, new slots regenerated, open offers rechecked.
- Blocked pair never appears, in either direction.
- Window crossing local midnight.
## Checks
Run the `verify` skill; `supabase test db` must cover every fixture above.
.claude/skills/verify/SKILL.md---
name: verify
description: Run after any code or migration change before claiming done; runs the real type, lint, test, database and bundling checks for this Expo + Supabase app.
---
# Verify
Run these steps in order from the repo root.
Do not say a task is done until every step that applies is green.
## Delegation
Steps 3 to 5 can produce long output.
Delegate them to the `test-runner` subagent when output is long.
It returns only failures with file:line and a short message.
## Steps
1. Types
`npx tsc --noEmit`
2. Lint
`npx eslint . --max-warnings 0`
3. Unit tests (report only failures)
`npx jest --silent`
4. Database, only if `supabase/migrations` changed
```
supabase db reset
supabase gen types typescript --local > src/types/db.ts
supabase test db
```
Run `npx tsc --noEmit` again if the regenerated types changed.
5. Edge Functions, only if `supabase/functions` changed
`deno test --allow-env --allow-net supabase/functions`
6. Smoke, to catch config and bundling errors
```
npx expo-doctor
npx expo export --platform ios --output-dir /tmp/expo-smoke
```
## Done means
- All applicable steps green.
- Paste the summary line of each command in your reply, for example
`Tests: 42 passed, 42 total` or `All tests successful.`
so `/goal` can judge the result from the conversation.
- If a step was skipped (no migration or function change), say so.
- If a step fails, fix and rerun from step 1; do not paste stale results.
INSTALL.md# Install
Setup for the language exchange app (Expo + Supabase).
## Prerequisites
- Node 20 or newer
- Docker (running, needed by `supabase start`)
- Supabase CLI (for example `brew install supabase/tap/supabase`)
- Deno (for Edge Function tests)
- Expo account and EAS CLI: `npm i -g eas-cli`, then `eas login`
- Xcode (iOS) and/or Android Studio (Android) for the dev client
- Claude Code 2.1.314 or newer
(`.claude/settings.json` uses `blockWritesOutsideWorkingDirectories`)
## First-time setup
1. If the repo is empty: `npx create-expo-app@latest . --template`
(pick a TypeScript template), then `supabase init`.
2. Copy the env file:
`cp .env.example .env`
3. Fill `.env` with placeholders only, then real local values:
- `EXPO_PUBLIC_SUPABASE_URL=YOUR_SUPABASE_URL`
- `EXPO_PUBLIC_SUPABASE_ANON_KEY=YOUR_SUPABASE_ANON_KEY`
- `REVENUECAT_API_KEY=YOUR_REVENUECAT_API_KEY`
4. Never commit `.env`. Claude is denied from reading it.
5. Server secrets (video secret, webhook secrets) go only to
Edge Functions, never to `.env` in the app:
`supabase secrets set VIDEO_API_KEY=... VIDEO_API_SECRET=...`
6. Start the local stack: `supabase start`, then `supabase status`
to confirm the containers are up and copy the local URL and anon key.
## Claude Code plugins
The official marketplace is added automatically. In Claude Code run:
```
/plugin install code-review@claude-plugins-official
/plugin install security-guidance@claude-plugins-official
/plugin install commit-commands@claude-plugins-official
/plugin install typescript-lsp@claude-plugins-official
```
## Running the build phases
1. Run `claude` in the repo root.
2. Open `PLAN.md` and copy the `/goal` prompt for the current phase.
Phase 1's prompt is the kickoff prompt.
3. Paste it and let it run until the checks pass or the turn bound hits.
4. Run `/code-review`, then commit (commit-commands plugin).
5. Run `/clear` before starting the next phase.
6. If a product rule changed, update `SPEC.md` before the next phase.
## Push notifications
Push does not work in Expo Go or simulators.
Build a dev client and install it on a physical device:
```
eas build --profile development --platform ios
eas build --profile development --platform android
```
Then run `npx expo start` and open the app in the dev build.
PLAN.md# Build plan
Six phases. Each ends with a `/goal` prompt you can paste into Claude Code.
Every goal states the end state, the check commands from the verify skill,
the constraints and a turn bound.
Between phases: run `/code-review`, commit, then `/clear`.
When a product rule changes, update SPEC.md in the same change.
Shared constraints for every phase:
no service role key in the client, RLS enabled and pgTAP-tested on every new table,
no new dependencies without listing them in the final message, no deploys or `supabase db push`.
## Phase 1: Foundation
Expo app scaffold, local Supabase, auth (email OTP, Apple/Google placeholders),
profiles with languages and tz, contact-detail trigger, blocks table and
`is_blocked()`, RLS and pgTAP. This goal is the kickoff prompt.
```
/goal Phase 1 Foundation for the language exchange app (read CLAUDE.md and SPEC.md first).
End state:
1. Expo TypeScript app with expo-router: screens for sign-in (email OTP via Supabase Auth), onboarding (display name, native languages, learning languages with CEFR level, IANA time zone auto-detected via expo-localization and editable, interests) and a profile view.
2. Supabase migrations: profiles, profile_languages, blocks(blocker_id, blocked_id), SQL function is_blocked(a uuid, b uuid) returning true in either direction, and a BEFORE INSERT/UPDATE trigger on profiles that rejects bio/display_name containing emails, phone numbers (7+ digits incl. spaces/dashes), URLs, @handles, or the words whatsapp, telegram, instagram, snapchat, discord.
3. RLS enabled on every table: users edit only their own profile; profiles readable by authenticated users except when is_blocked(auth.uid(), id); users manage only their own blocks.
4. Client-side mirror of the contact-detail check in src/lib/contactFilter.ts with jest tests (at least 10 cases, including false positives like '3 years' and 'I love C++').
5. Generated types in src/types/db.ts used by a typed Supabase client in src/lib/supabase.ts (anon key from EXPO_PUBLIC_ env only).
Check (run via the verify skill, delegate long output to test-runner, and paste each command's summary line):
- `npx tsc --noEmit` clean
- `npx eslint . --max-warnings 0` clean
- `npx jest --silent` all pass
- `supabase db reset` succeeds, then `supabase test db` passes with pgTAP tests proving: owner can update own profile, other user cannot, blocked user cannot select blocker's profile and vice versa, trigger rejects 'call me +00 000 000 000' and 'insta @maria' and accepts 'I studied 3 years'
- `npx expo export --platform ios --output-dir /tmp/expo-smoke` bundles without errors
Constraints:
- Follow the db-change skill for migrations; no service role key in app code; no real secrets, use placeholders.
- Store time zone as IANA name text; no scheduling logic yet.
- List any new npm dependencies in your final message; do not run `supabase db push` or any deploy.
- Keep components small; no UI library beyond what Expo ships unless you justify it.
Stop when all checks pass and are shown, or after 40 turns, summarizing what remains.
```
## Phase 2: Availability and matching
Weekly rules UI, slot expansion function, `find_matches()`, offer/accept flow,
pgTAP tests for DST and odd offsets.
```
/goal Phase 2 availability and matching (read SPEC.md and the timezone-matching skill first).
End state: availability_rules and availability_slots tables with RLS; a slot expansion function run nightly by pg_cron and on rule/tz change (14 days ahead, via AT TIME ZONE); find_matches() SQL function implementing SPEC.md pairing and ranking; offers table with accept flow and 12 h expiry; weekly rules UI that only calls server functions.
Check (verify skill, paste each summary line): `npx tsc --noEmit`, `npx eslint . --max-warnings 0`, `npx jest --silent`, `supabase db reset`, `supabase gen types typescript --local > src/types/db.ts`, `supabase test db` passes with at least 15 matching tests covering: US DST 2026-03-08 and EU DST 2026-03-29 (America/New_York + Europe/London), Asia/Kolkata +5:30, blocked pair exclusion, over-quota and paused users excluded, expired offers.
Constraints: no slot or match computation in the client; date-fns-tz for display only; RLS on new tables; no service role in client; list new deps; no deploys.
Stop when all checks pass and are shown, or after 40 turns, summarizing what remains.
```
## Phase 3: Call experience
Video adapter, token minting, server-anchored timer, synced card deck.
```
/goal Phase 3 call experience (read SPEC.md Call section first).
End state: video adapter interface in src/lib/video/ (join, leave, mute, onParticipantLeft) with a mock implementation used in tests; token-minting Edge Function that validates the JWT and that the caller is a call participant (VIDEO_API_SECRET from Edge Function env only); calls table with started_at and coin-flip first language; useCallPhase(startedAt) hook deriving halves from started_at; conversation cards table and draw-next-card synced via Realtime; call screen with switch banner and sound, accessible controls.
Check (verify skill, paste each summary line): `npx tsc --noEmit`, `npx eslint . --max-warnings 0`, `npx jest --silent` including fake-timer tests that the phase switches at 15:00 and ends at 30:00 even with a skewed device clock, `supabase test db`, `deno test --allow-env --allow-net supabase/functions` passes.
Constraints: no provider SDK calls outside the adapter; no tokens logged; RLS on new tables; no service role in client; list new deps; no deploys.
Stop when all checks pass and are shown, or after 40 turns, summarizing what remains.
```
## Phase 4: Ratings and flashcards
Rating screen, auto-report, shared deck, SM-2, review screen.
```
/goal Phase 4 ratings and flashcards (read SPEC.md Post-call and Flashcards first).
End state: ratings table (private, aggregate view for ranking) and rating screen with tags; 'inappropriate' tag auto-creates a report server-side; per-pair shared deck created at call, cards, and per-user review state; SM-2 as a pure function in src/lib/srs.ts; review screen listing due cards across decks.
Check (verify skill, paste each summary line): `npx tsc --noEmit`, `npx eslint . --max-warnings 0`, `npx jest --silent` with table-driven srs tests covering grades 0-5, ease floor and interval growth, `supabase db reset` then `supabase test db` with RLS tests proving only the pair can read the deck and a blocked partner cannot add cards.
Constraints: card text passes the contact-detail check; RLS on new tables; no service role in client; list new deps; no deploys.
Stop when all checks pass and are shown, or after 30 turns, summarizing what remains.
```
## Phase 5: Safety, no-show, push
Report flows, moderation queue, no-show cron, penalty and pause, reminders.
```
/goal Phase 5 safety, no-show and push (read SPEC.md Safety, No-show and Push first).
End state: report and block from profile, call screen and post-call; moderation queue screen gated by the admin JWT claim with warn, suspend N days, ban; no-show detection via pg_cron (5 min after start); match_priority penalty -20 floor 0, +5 per completed call, 7-day pause after 3 no-shows in 30 days; 10-min reminder cron calling an Edge Function that sends Expo push; offer and accept notifications.
Check (verify skill, paste each summary line): `npx tsc --noEmit`, `npx eslint . --max-warnings 0`, `npx jest --silent`, `supabase db reset` then `supabase test db` with pgTAP tests for penalty math, floor, recovery and pause, `deno test --allow-env --allow-net supabase/functions` with a test that the reminder selects only calls starting in 9-11 min and not already reminded.
Constraints: no-show and penalty logic server-side only; push tokens never logged; RLS on new tables; no service role in client; list new deps; no deploys.
Stop when all checks pass and are shown, or after 40 turns, summarizing what remains.
```
## Phase 6: Subscriptions and quota
RevenueCat SDK, paywall, webhook, quota function.
```
/goal Phase 6 subscriptions and quota (read SPEC.md Quota first).
End state: RevenueCat SDK configured with placeholder REVENUECAT_API_KEY; paywall screen; webhook Edge Function that verifies the signature, validates input with zod and syncs the 'unlimited' entitlement; can_schedule_call() enforcing 3 calls per ISO week in the user's zone, excluding calls where the partner no-showed, used by matching and offer accept.
Check (verify skill, paste each summary line): `npx tsc --noEmit`, `npx eslint . --max-warnings 0`, `npx jest --silent`, `supabase db reset` then `supabase test db` with tests for the week boundary in the user's zone (Sunday night vs Monday in Asia/Kolkata and America/New_York) and entitlement on/off, `deno test --allow-env --allow-net supabase/functions` with signature accept/reject tests.
Constraints: quota only server-side; no direct table insert can bypass it; RLS on new tables; no service role in client; list new deps; no deploys.
Stop when all checks pass and are shown, or after 30 turns, summarizing what remains.
```
## After each phase
1. Run `/code-review` and ask the security-reviewer agent to check
phases that touch migrations, Edge Functions, safety or payments.
2. Commit.
3. Run `/clear` before pasting the next goal.
4. If a rule changed, update SPEC.md.
REVIEW.md# Review checklist
Imported by CLAUDE.md so `/code-review` checks these project risks.
Flag each violation with file:line and a one-line fix.
## Database and RLS
- [ ] RLS is enabled in the same migration as every new table.
- [ ] Every new table has a pgTAP test in `supabase/tests/`.
- [ ] No policy uses `true` for select on user data.
- [ ] Migrations are additive and reversible (no destructive drops
without a follow-up plan).
- [ ] `src/types/db.ts` was regenerated after the migration.
## Secrets and tokens
- [ ] No service role key in app code or in any `EXPO_PUBLIC_` var.
- [ ] No `VIDEO_API_SECRET` in app code or in any `EXPO_PUBLIC_` var.
- [ ] Push tokens and video tokens are never logged
(client logs, Edge Function logs, error reports).
## Time and scheduling
- [ ] All timestamps are `timestamptz`.
- [ ] No `new Date()` math for scheduling in the client.
- [ ] Time zone conversions in the client are for display only
(date-fns-tz).
## Safety
- [ ] Every new query touching other users excludes blocked pairs
via `is_blocked(a, b)`.
- [ ] User-entered text that others can see goes through the
contact-detail check (DB trigger, plus client check for UX).
## Business rules
- [ ] Quota logic runs only server-side.
- [ ] No-show detection and penalties run only server-side.
- [ ] The client calls Postgres functions or Edge Functions;
it does not reimplement the rule.
## Edge Functions and webhooks
- [ ] Edge Functions validate the JWT.
- [ ] Edge Functions validate input with zod.
- [ ] Webhooks (RevenueCat) verify the signature before any write.
## Mobile
- [ ] No unbounded `FlatList` data; lists are paginated or capped.
- [ ] Timers and subscriptions are cleaned up in `useEffect` returns.
- [ ] Call controls (mute, leave, next card, report, block) have
accessibility labels.
SPEC.md# Product spec: language exchange video calls
Two learners are matched for a 30 minute video call, 15 minutes in each language.
This file holds the exact product rules. Update it when a rule changes.
## Users and profiles
- Profile fields: display name, native languages, learning languages
with CEFR level (A1 to C2), IANA time zone (for example `Europe/London`), interests.
- No contact details are allowed in any profile text: no emails, phone numbers,
URLs, @handles, or mentions of whatsapp, telegram, insta/instagram, snapchat, discord.
- The DB trigger is the source of truth. The client mirrors the check for UX only.
- Age gate: users must confirm they are 18 or older before onboarding completes.
## Availability
- Users set weekly recurring windows in their own local zone
(weekday, local start time, local end time, IANA tz).
- Users can add one-off exceptions: extra windows or blocked-out dates.
- Windows are expanded to UTC 30 minute slots (tstzrange) for the next 14 days:
- nightly by pg_cron
- immediately when a user changes rules, exceptions or time zone
- Expansion runs server-side only. The client never computes slots or matches.
## Matching
Pair users A and B when all of these hold:
- A.native is in B.learning and B.native is in A.learning
- they share an overlapping 30 minute slot
- `is_blocked(A, B)` is false
- both are under quota and neither is in a matching pause
Rank candidates by:
1. `match_priority` descending
2. level closeness (smaller CEFR gap first)
3. number of shared interests
4. not matched with each other in the last 7 days (preferred)
5. tie-breaker: oldest waiting offer first
Offer flow:
- A match creates an offer to both users.
- Both must accept within 12 hours, or the offer expires and the slot is freed.
## Call
- Length: 30 minutes.
- The first-half language is chosen by coin flip and stored on the call row.
- The call has a server-set `started_at`. Halves are derived from
`started_at + 15 min` and `started_at + 30 min`, so both phones switch
at the same moment despite clock drift.
- At 15:00 the app plays a sound and shows a banner to switch language.
- Conversation cards guide the topic:
- card table: language-agnostic prompt with translations, level, topic tag
- either user can draw the next card; the draw syncs to both via Realtime
## No-show
- A participant who has not joined within 5 minutes of the start is marked `no_show`.
- Penalty: `match_priority` drops by 20 (floor 0).
- Recovery: +5 for each completed call.
- 3 no-shows in 30 days triggers a 7 day matching pause.
- The other user's call does not count toward their quota.
## Post-call
- Rating 1 to 5, plus optional tags: friendly, patient, late, inappropriate.
- Ratings are private. Only the aggregate feeds into ranking.
- The `inappropriate` tag automatically creates a report.
## Flashcards
- A shared deck is created per pair at their first call.
- Either user adds a card: word or phrase, translation, note.
- Each user keeps their own review state per card using SM-2
(ease, interval, due_at).
- The review screen shows all due cards across the user's decks.
- Blocked pairs lose access to sharing new cards.
## Safety
- Report: reason enum + free text + optional call id.
- Report and block are available from the profile, the call screen and post-call.
- Block is immediate and silent. The blocked user is not notified.
- `is_blocked(a, b)` excludes blocked users from matching, chat,
flashcard sharing and search.
- Moderation queue table with status `open`, `actioned`, `dismissed`.
- Admin role comes from a JWT claim.
- Admin actions: warn, suspend N days, ban.
## Quota
- Free tier: 3 completed or scheduled calls per ISO week,
with week boundaries in the user's own time zone.
- Calls where the other user no-showed do not count.
- Subscription: RevenueCat entitlement `unlimited` removes the limit.
- The entitlement is synced to Supabase by a RevenueCat webhook Edge Function
that verifies the signature.
- Quota is enforced server-side in `can_schedule_call()`.
## Push notifications
- Reminder 10 minutes before a call: pg_cron selects upcoming calls and an
Edge Function sends an Expo push. Each call is reminded once.
- Match offer notification when an offer is created.
- Accept notification when the other user accepts.
- Push needs a dev build on a physical device.
## Out of scope for v1
- In-app text chat outside calls
- Group calls
- Web app
## Open questions
- Video provider: LiveKit or Daily (behind the `src/lib/video/` adapter,
placeholder `VIDEO_PROVIDER`).
- Recording policy: default is no recording. Confirm legal position before changing.
- Minimum age by region: 18+ globally for now; check regional requirements.