From b10eae1bf3a947e0beadb6ad03841f0bf6094c0b Mon Sep 17 00:00:00 2001 From: Jamie Brunton Date: Mon, 29 Dec 2025 23:10:12 +0000 Subject: [PATCH] Update --- .claude/settings.local.json | 7 + .dockerignore | 14 + CLAUDE.md | 90 +++++ apps/admin/Dockerfile.dev | 20 ++ apps/admin/docker-compose.yml | 16 + apps/admin/src/App.tsx | 98 +++-- apps/admin/src/config.ts | 7 + apps/admin/src/providers/auditDataProvider.ts | 122 +++++++ apps/admin/src/providers/authProvider.ts | 110 +++++- apps/admin/src/providers/supabaseProvider.ts | 38 +- apps/admin/src/resources/auditLogs.tsx | 64 ++++ apps/admin/src/resources/couples.tsx | 21 ++ apps/admin/src/resources/matches.tsx | 59 +++ apps/admin/src/resources/messages.tsx | 25 ++ apps/admin/src/resources/users.tsx | 89 ++++- apps/admin/tsconfig.json | 20 ++ apps/admin/vite.config.ts | 3 + .../migrations/20251229100000_admin_rbac.sql | 69 ++++ .../migrations/20251229100001_audit_log.sql | 81 +++++ .../20251229100002_admin_rls_policies.sql | 79 ++++ docs/authentication.md | 337 ++++++++++++++++++ docs/couple-pairing.md | 287 +++++++++++++++ docs/match-notifications.md | 293 +++++++++++++++ docs/question-selection.md | 154 ++++++++ docs/schema.md | 182 ++++++++++ docs/subscription-system.md | 277 ++++++++++++++ 26 files changed, 2509 insertions(+), 53 deletions(-) create mode 100644 .claude/settings.local.json create mode 100644 .dockerignore create mode 100644 CLAUDE.md create mode 100644 apps/admin/Dockerfile.dev create mode 100644 apps/admin/docker-compose.yml create mode 100644 apps/admin/src/providers/auditDataProvider.ts create mode 100644 apps/admin/src/resources/auditLogs.tsx create mode 100644 apps/admin/src/resources/couples.tsx create mode 100644 apps/admin/src/resources/matches.tsx create mode 100644 apps/admin/src/resources/messages.tsx create mode 100644 apps/admin/tsconfig.json create mode 100644 apps/supabase/migrations/20251229100000_admin_rbac.sql create mode 100644 apps/supabase/migrations/20251229100001_audit_log.sql create mode 100644 apps/supabase/migrations/20251229100002_admin_rls_policies.sql create mode 100644 docs/authentication.md create mode 100644 docs/couple-pairing.md create mode 100644 docs/match-notifications.md create mode 100644 docs/question-selection.md create mode 100644 docs/schema.md create mode 100644 docs/subscription-system.md diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 0000000..1e2b006 --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,7 @@ +{ + "permissions": { + "allow": [ + "Bash(wc:*)" + ] + } +} diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..a9dd56c --- /dev/null +++ b/.dockerignore @@ -0,0 +1,14 @@ +node_modules +.git +.gitignore +*.md +.env* +.DS_Store +dist +.expo +android +ios +*.log +.turbo +coverage +.nyc_output diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..7b11390 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,90 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +Sauci is a couples intimacy app featuring swipeable question packs with partner matching. Users swipe on questions (yes/no/maybe), and when both partners answer positively, a "match" is created that unlocks a chat thread for that question. + +## Commands + +```bash +# Install dependencies (from root) +npm install + +# Start all apps in development +npm run dev + +# Run specific apps +cd apps/mobile && npm run dev # Expo mobile app +cd apps/admin && npm run dev # React Admin dashboard + +# Type checking +npm run typecheck + +# Linting +npm run lint + +# Build all +npm run build +``` + +### Mobile-specific commands +```bash +cd apps/mobile +npm run ios # Run on iOS simulator +npm run android # Run on Android emulator +npm run web # Run in browser +``` + +### Supabase commands +```bash +supabase start # Start local Supabase +supabase db reset # Reset DB and run all migrations +supabase functions serve # Run edge functions locally +supabase migration new # Create new migration +``` + +## Architecture + +### Monorepo Structure (Turborepo) +- `apps/mobile` - Expo React Native app (expo-router for navigation) +- `apps/admin` - React Admin dashboard for content management +- `apps/supabase` - Database migrations and edge functions +- `packages/shared` - Shared TypeScript types (`@sauci/shared`) + +### Mobile App Architecture +- **Routing**: Expo Router with file-based routing (`app/` directory) + - `(auth)/` - Authentication screens + - `(app)/` - Main app screens (protected) +- **State Management**: Zustand stores in `src/store/index.ts` + - `useAuthStore` - User, couple, partner state + - `useMatchStore` - Matches and notifications + - `usePacksStore` - Question packs and enabled packs per couple + - `useMessageStore` - Chat messages and unread counts + - `useSubscriptionStore` - RevenueCat subscription state +- **Backend**: Supabase client in `src/lib/supabase.ts` +- **UI Components**: Glass-morphism themed components in `src/components/ui/` + +### Data Model (Core Entities) +- **Profile** - User profile extending Supabase auth (has `couple_id`) +- **Couple** - Two users linked via invite code +- **QuestionPack** - Collection of questions (can be premium) +- **Question** - Individual question with intensity level (1-5) +- **Response** - User's answer (yes/no/maybe) to a question +- **Match** - Created when both partners answer positively (yes_yes, yes_maybe, maybe_maybe) + +### Edge Functions +Edge functions in `apps/supabase/functions/` handle complex operations: +- `submit-response` - Saves response, checks for matches, triggers notifications +- `manage-couple` - Join/leave couple operations +- `send-notification` - Push notifications for matches +- `revenuecat-webhook` - Subscription webhook handler +- `sync-subscription` - Syncs RevenueCat subscription status + +### Key Patterns +- Responses use UPSERT with `onConflict: "user_id,question_id"` +- Match detection happens in `submit-response` edge function after each response +- Real-time subscriptions used for chat messages +- RevenueCat handles in-app purchases and subscription management +- RLS policies enforce couple-level data isolation diff --git a/apps/admin/Dockerfile.dev b/apps/admin/Dockerfile.dev new file mode 100644 index 0000000..14f4b28 --- /dev/null +++ b/apps/admin/Dockerfile.dev @@ -0,0 +1,20 @@ +FROM node:20-alpine + +WORKDIR /app + +COPY package*.json ./ +COPY turbo.json ./ +COPY apps/admin/package.json ./apps/admin/ +COPY packages/shared/package.json ./packages/shared/ + +RUN npm install + +COPY . . + +RUN npx turbo run build --filter=@sauci/shared + +WORKDIR /app/apps/admin + +EXPOSE 3001 + +CMD ["npm", "run", "dev"] diff --git a/apps/admin/docker-compose.yml b/apps/admin/docker-compose.yml new file mode 100644 index 0000000..8897dbc --- /dev/null +++ b/apps/admin/docker-compose.yml @@ -0,0 +1,16 @@ +services: + admin: + build: + context: ../.. + dockerfile: apps/admin/Dockerfile.dev + ports: + - "3000:3001" + environment: + - VITE_SUPABASE_URL=${VITE_SUPABASE_URL} + - VITE_SUPABASE_ANON_KEY=${VITE_SUPABASE_ANON_KEY} + volumes: + - ./src:/app/apps/admin/src + - ./index.html:/app/apps/admin/index.html + - ./vite.config.ts:/app/apps/admin/vite.config.ts + - ./tsconfig.json:/app/apps/admin/tsconfig.json + restart: unless-stopped diff --git a/apps/admin/src/App.tsx b/apps/admin/src/App.tsx index 4a6fca4..48e87d8 100755 --- a/apps/admin/src/App.tsx +++ b/apps/admin/src/App.tsx @@ -1,34 +1,80 @@ import { Admin, Resource } from "react-admin"; -import { dataProvider } from "./providers/supabaseProvider"; +import { auditDataProvider } from "./providers/auditDataProvider"; import { PackList, PackEdit, PackCreate } from "./resources/packs"; import { QuestionList, QuestionEdit, QuestionCreate } from "./resources/questions"; -import { ProfileList, ResponseList } from "./resources/users"; -import { authProvider } from "./providers/authProvider"; +import { ProfileList, ProfileShow, ProfileEdit, ResponseList } from "./resources/users"; +import { CoupleList } from "./resources/couples"; +import { MatchList, MatchShow } from "./resources/matches"; +import { AuditLogList } from "./resources/auditLogs"; +import { authProvider, AdminPermissions } from "./providers/authProvider"; const App = () => ( - - - - - - + + {(permissions: AdminPermissions) => ( + <> + {/* Question Packs - accessible to all admins */} + + + {/* Questions - accessible to all admins */} + + + {/* Super admin only resources */} + {permissions?.role === 'super_admin' && ( + <> + + + + + + + + )} + + )} ); diff --git a/apps/admin/src/config.ts b/apps/admin/src/config.ts index 3dd2750..c373a35 100755 --- a/apps/admin/src/config.ts +++ b/apps/admin/src/config.ts @@ -1,4 +1,11 @@ +import { createClient } from '@supabase/supabase-js'; + export const supabaseConfig = { url: import.meta.env.VITE_SUPABASE_URL, anonKey: import.meta.env.VITE_SUPABASE_ANON_KEY, }; + +export const supabase = createClient( + supabaseConfig.url, + supabaseConfig.anonKey +); diff --git a/apps/admin/src/providers/auditDataProvider.ts b/apps/admin/src/providers/auditDataProvider.ts new file mode 100644 index 0000000..fb3b4d8 --- /dev/null +++ b/apps/admin/src/providers/auditDataProvider.ts @@ -0,0 +1,122 @@ +import { DataProvider, CreateParams, UpdateParams, DeleteParams, DeleteManyParams } from 'react-admin'; +import { dataProvider as baseDataProvider, supabase } from './supabaseProvider'; + +// Tables to audit +const AUDITED_TABLES = [ + 'question_packs', + 'questions', + 'profiles', + 'couples', + 'responses', + 'matches', + 'messages', + 'subscriptions', + 'feedback', +]; + +const logAction = async ( + tableName: string, + recordId: string, + action: 'INSERT' | 'UPDATE' | 'DELETE', + oldValues?: Record | null, + newValues?: Record | null +) => { + try { + await supabase.rpc('log_admin_action', { + p_table_name: tableName, + p_record_id: recordId, + p_action: action, + p_old_values: oldValues || null, + p_new_values: newValues || null, + }); + } catch (error) { + console.error('Failed to log audit action:', error); + // Don't throw - logging failure shouldn't block the operation + } +}; + +export const auditDataProvider: DataProvider = { + ...baseDataProvider, + + create: async ( + resource: string, + params: CreateParams + ) => { + const result = await baseDataProvider.create(resource, params); + + if (AUDITED_TABLES.includes(resource)) { + await logAction(resource, result.data.id, 'INSERT', null, result.data as Record); + } + + return result; + }, + + update: async ( + resource: string, + params: UpdateParams + ) => { + // Fetch old values before update + let oldValues: Record | null = null; + if (AUDITED_TABLES.includes(resource)) { + const { data } = await supabase + .from(resource) + .select('*') + .eq('id', params.id) + .single(); + oldValues = data; + } + + const result = await baseDataProvider.update(resource, params); + + if (AUDITED_TABLES.includes(resource)) { + await logAction(resource, params.id as string, 'UPDATE', oldValues, result.data as Record); + } + + return result; + }, + + delete: async ( + resource: string, + params: DeleteParams + ) => { + // Fetch old values before delete + let oldValues: Record | null = null; + if (AUDITED_TABLES.includes(resource)) { + const { data } = await supabase + .from(resource) + .select('*') + .eq('id', params.id) + .single(); + oldValues = data; + } + + const result = await baseDataProvider.delete(resource, params); + + if (AUDITED_TABLES.includes(resource)) { + await logAction(resource, params.id as string, 'DELETE', oldValues, null); + } + + return result; + }, + + deleteMany: async (resource: string, params: DeleteManyParams) => { + // Fetch old values before delete + if (AUDITED_TABLES.includes(resource)) { + const { data: oldRecords } = await supabase + .from(resource) + .select('*') + .in('id', params.ids); + + const result = await baseDataProvider.deleteMany(resource, params); + + // Log each deletion + for (const record of oldRecords || []) { + await logAction(resource, record.id, 'DELETE', record, null); + } + + return result; + } + + return baseDataProvider.deleteMany(resource, params); + }, +}; diff --git a/apps/admin/src/providers/authProvider.ts b/apps/admin/src/providers/authProvider.ts index aebbf13..91b62c1 100755 --- a/apps/admin/src/providers/authProvider.ts +++ b/apps/admin/src/providers/authProvider.ts @@ -1,6 +1,37 @@ import { AuthProvider } from 'react-admin'; import { supabase } from '../config'; +// Define admin roles +export type AdminRole = 'pack_creator' | 'super_admin'; + +// Permission structure +export interface AdminPermissions { + role: AdminRole; + permissions: Array<{ action: string; resource: string }>; +} + +// Define permissions by role +const ROLE_PERMISSIONS: Record> = { + pack_creator: [ + { action: 'list', resource: 'question_packs' }, + { action: 'create', resource: 'question_packs' }, + { action: 'edit', resource: 'question_packs' }, + { action: 'delete', resource: 'question_packs' }, + { action: 'show', resource: 'question_packs' }, + { action: 'list', resource: 'questions' }, + { action: 'create', resource: 'questions' }, + { action: 'edit', resource: 'questions' }, + { action: 'delete', resource: 'questions' }, + { action: 'show', resource: 'questions' }, + ], + super_admin: [ + // Full access to all resources + { action: '*', resource: '*' }, + ], +}; + +const PERMISSIONS_KEY = 'admin_permissions'; + export const authProvider: AuthProvider = { login: async ({ username, password }) => { const { data, error } = await supabase.auth.signInWithPassword({ @@ -10,21 +41,96 @@ export const authProvider: AuthProvider = { if (error) { throw new Error(error.message); } + + // Fetch admin role + const { data: adminData, error: adminError } = await supabase + .from('admin_users') + .select('role') + .eq('user_id', data.user.id) + .single(); + + if (adminError || !adminData) { + await supabase.auth.signOut(); + throw new Error('Access denied. You are not an administrator.'); + } + + // Store permissions + const permissions: AdminPermissions = { + role: adminData.role as AdminRole, + permissions: ROLE_PERMISSIONS[adminData.role as AdminRole], + }; + localStorage.setItem(PERMISSIONS_KEY, JSON.stringify(permissions)); + return Promise.resolve(); }, + logout: async () => { + localStorage.removeItem(PERMISSIONS_KEY); await supabase.auth.signOut(); return Promise.resolve(); }, + checkError: async (error) => { if (error.status === 401 || error.status === 403) { + localStorage.removeItem(PERMISSIONS_KEY); return Promise.reject(); } return Promise.resolve(); }, + checkAuth: async () => { const session = await supabase.auth.getSession(); - return session.data.session ? Promise.resolve() : Promise.reject(); + const permissions = localStorage.getItem(PERMISSIONS_KEY); + + if (!session.data.session || !permissions) { + return Promise.reject(); + } + return Promise.resolve(); + }, + + getPermissions: async () => { + const permissions = localStorage.getItem(PERMISSIONS_KEY); + if (!permissions) { + return Promise.reject(); + } + return JSON.parse(permissions) as AdminPermissions; + }, + + canAccess: async ({ resource, action }) => { + const permissionsStr = localStorage.getItem(PERMISSIONS_KEY); + if (!permissionsStr) { + return false; + } + + const { permissions } = JSON.parse(permissionsStr) as AdminPermissions; + + // Check for wildcard (super_admin) + if (permissions.some(p => p.action === '*' && p.resource === '*')) { + return true; + } + + // Check specific permission + return permissions.some( + p => p.resource === resource && (p.action === action || p.action === '*') + ); + }, + + getIdentity: async () => { + const { data } = await supabase.auth.getUser(); + if (!data.user) { + throw new Error('No user'); + } + + const permissionsStr = localStorage.getItem(PERMISSIONS_KEY); + const role = permissionsStr + ? (JSON.parse(permissionsStr) as AdminPermissions).role + : 'unknown'; + + return { + id: data.user.id, + fullName: data.user.email, + avatar: undefined, + role, + }; }, - getPermissions: () => Promise.resolve(), }; diff --git a/apps/admin/src/providers/supabaseProvider.ts b/apps/admin/src/providers/supabaseProvider.ts index 8d7a71d..41982cc 100755 --- a/apps/admin/src/providers/supabaseProvider.ts +++ b/apps/admin/src/providers/supabaseProvider.ts @@ -1,22 +1,20 @@ -import { createClient } from '@supabase/supabase-js'; -import { supabaseConfig } from '../config'; - -export const supabase = createClient( - supabaseConfig.url, - supabaseConfig.anonKey -); - -// Custom data provider wrapper if needed for ra-data-supabase import { supabaseDataProvider } from 'ra-supabase'; +import { supabase } from '../config'; -export const dataProvider = supabaseDataProvider( - supabase, - { - resources: { - question_packs: 'question_packs', - questions: 'questions', - profiles: 'profiles', - couples: 'couples' - } - } -); +export { supabase }; + +const resources = { + question_packs: ['id', 'name', 'description', 'icon', 'is_premium', 'is_public', 'sort_order', 'created_at'], + questions: ['id', 'pack_id', 'text', 'partner_text', 'intensity', 'created_at'], + profiles: ['id', 'name', 'avatar_url', 'is_premium', 'couple_id', 'created_at'], + couples: ['id', 'invite_code', 'created_at'], + responses: ['id', 'user_id', 'question_id', 'couple_id', 'answer', 'created_at'], + matches: ['id', 'couple_id', 'question_id', 'match_type', 'is_new', 'created_at'], + messages: ['id', 'match_id', 'user_id', 'content', 'read_at', 'created_at'], + subscriptions: ['id', 'user_id', 'status', 'product_id', 'expires_at', 'created_at'], + feedback: ['id', 'user_id', 'type', 'title', 'description', 'status', 'created_at'], + audit_logs: ['id', 'table_name', 'record_id', 'action', 'old_values', 'new_values', 'changed_fields', 'admin_user_id', 'admin_role', 'created_at'], + admin_users: ['id', 'user_id', 'role', 'created_at'], +}; + +export const dataProvider = supabaseDataProvider(supabase, resources); diff --git a/apps/admin/src/resources/auditLogs.tsx b/apps/admin/src/resources/auditLogs.tsx new file mode 100644 index 0000000..5d1a321 --- /dev/null +++ b/apps/admin/src/resources/auditLogs.tsx @@ -0,0 +1,64 @@ +import { + List, + Datagrid, + TextField, + DateField, + FunctionField, + SelectInput, + TextInput, + DateInput, +} from 'react-admin'; + +const auditLogFilters = [ + , + , + , + , +]; + +export const AuditLogList = () => ( + + + + + { + const actionMap: Record = { + INSERT: 'Create', + UPDATE: 'Update', + DELETE: 'Delete', + }; + return actionMap[record.action] || record.action; + }} + /> + + + + record.changed_fields?.join(', ') || '-' + } + /> + { + if (record.action === 'INSERT') return 'New record created'; + if (record.action === 'DELETE') return 'Record deleted'; + return `${record.changed_fields?.length || 0} fields changed`; + }} + /> + + +); diff --git a/apps/admin/src/resources/couples.tsx b/apps/admin/src/resources/couples.tsx new file mode 100644 index 0000000..1ad1c8e --- /dev/null +++ b/apps/admin/src/resources/couples.tsx @@ -0,0 +1,21 @@ +import { + List, + Datagrid, + TextField, + DateField, + TextInput, +} from 'react-admin'; + +const coupleFilters = [ + , +]; + +export const CoupleList = () => ( + + + + + + + +); diff --git a/apps/admin/src/resources/matches.tsx b/apps/admin/src/resources/matches.tsx new file mode 100644 index 0000000..24bf52d --- /dev/null +++ b/apps/admin/src/resources/matches.tsx @@ -0,0 +1,59 @@ +import { + Show, + SimpleShowLayout, + TextField, + DateField, + ReferenceField, + ReferenceManyField, + List, + Datagrid, + BooleanField, + Labeled, +} from 'react-admin'; + +export const MatchShow = () => ( + + + + + + + + + + + + + + + + + + + + + + + + +); + +export const MatchList = () => ( + + + + + + + + + + + + + +); diff --git a/apps/admin/src/resources/messages.tsx b/apps/admin/src/resources/messages.tsx new file mode 100644 index 0000000..51feb1e --- /dev/null +++ b/apps/admin/src/resources/messages.tsx @@ -0,0 +1,25 @@ +import { + List, + Datagrid, + TextField, + DateField, + ReferenceField, + TextInput, +} from 'react-admin'; + +const messageFilters = [ + , +]; + +export const MessageList = () => ( + + + + + + + + + + +); diff --git a/apps/admin/src/resources/users.tsx b/apps/admin/src/resources/users.tsx index 22d1e7b..90aa546 100755 --- a/apps/admin/src/resources/users.tsx +++ b/apps/admin/src/resources/users.tsx @@ -1,12 +1,29 @@ -import { List, Datagrid, TextField, BooleanField, DateField, ReferenceField } from 'react-admin'; +import { + List, + Datagrid, + TextField, + BooleanField, + DateField, + ReferenceField, + Edit, + SimpleForm, + TextInput, + BooleanInput, + ReferenceInput, + SelectInput, + Show, + SimpleShowLayout, + ReferenceManyField, + useRecordContext, + TabbedShowLayout, +} from 'react-admin'; export const ProfileList = () => ( - + - - + @@ -14,9 +31,71 @@ export const ProfileList = () => ( ); +export const ProfileShow = () => ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +); + +export const ProfileEdit = () => ( + + + + + + + + + +); + export const ResponseList = () => ( - + diff --git a/apps/admin/tsconfig.json b/apps/admin/tsconfig.json new file mode 100644 index 0000000..a4c834a --- /dev/null +++ b/apps/admin/tsconfig.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "target": "ES2020", + "useDefineForClassFields": true, + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "module": "ESNext", + "skipLibCheck": true, + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "react-jsx", + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["src"] +} diff --git a/apps/admin/vite.config.ts b/apps/admin/vite.config.ts index 0118b07..7f230af 100755 --- a/apps/admin/vite.config.ts +++ b/apps/admin/vite.config.ts @@ -6,5 +6,8 @@ export default defineConfig({ server: { host: true, port: 3001, + watch: { + usePolling: true, + }, }, }); diff --git a/apps/supabase/migrations/20251229100000_admin_rbac.sql b/apps/supabase/migrations/20251229100000_admin_rbac.sql new file mode 100644 index 0000000..63fa27d --- /dev/null +++ b/apps/supabase/migrations/20251229100000_admin_rbac.sql @@ -0,0 +1,69 @@ +-- Admin Role-Based Access Control +-- Creates admin_users table and helper functions for role management + +-- Create admin role enum +CREATE TYPE public.admin_role AS ENUM ('pack_creator', 'super_admin'); + +-- Admin users table (tracks which auth.users are admins and their role) +CREATE TABLE public.admin_users ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL UNIQUE REFERENCES auth.users(id) ON DELETE CASCADE, + role public.admin_role NOT NULL, + created_at TIMESTAMPTZ DEFAULT NOW(), + created_by UUID REFERENCES auth.users(id), + updated_at TIMESTAMPTZ DEFAULT NOW() +); + +-- Index for quick lookups +CREATE INDEX idx_admin_users_user_id ON public.admin_users(user_id); +CREATE INDEX idx_admin_users_role ON public.admin_users(role); + +-- Updated_at trigger +CREATE TRIGGER update_admin_users_updated_at + BEFORE UPDATE ON public.admin_users + FOR EACH ROW EXECUTE FUNCTION public.update_updated_at(); + +-- Enable RLS +ALTER TABLE public.admin_users ENABLE ROW LEVEL SECURITY; + +-- Policy: Allow admins to read their own record +CREATE POLICY "Admin users can read own record" + ON public.admin_users FOR SELECT + USING (auth.uid() = user_id); + +-- Helper function to check if user is admin +CREATE OR REPLACE FUNCTION public.is_admin(check_user_id UUID DEFAULT auth.uid()) +RETURNS BOOLEAN AS $$ +BEGIN + RETURN EXISTS ( + SELECT 1 FROM public.admin_users WHERE user_id = check_user_id + ); +END; +$$ LANGUAGE plpgsql SECURITY DEFINER STABLE; + +-- Helper function to get admin role +CREATE OR REPLACE FUNCTION public.get_admin_role(check_user_id UUID DEFAULT auth.uid()) +RETURNS public.admin_role AS $$ +DECLARE + user_role public.admin_role; +BEGIN + SELECT role INTO user_role FROM public.admin_users WHERE user_id = check_user_id; + RETURN user_role; +END; +$$ LANGUAGE plpgsql SECURITY DEFINER STABLE; + +-- Helper function to check if user is super admin +CREATE OR REPLACE FUNCTION public.is_super_admin(check_user_id UUID DEFAULT auth.uid()) +RETURNS BOOLEAN AS $$ +BEGIN + RETURN EXISTS ( + SELECT 1 FROM public.admin_users + WHERE user_id = check_user_id AND role = 'super_admin' + ); +END; +$$ LANGUAGE plpgsql SECURITY DEFINER STABLE; + +-- Grant execute permissions on helper functions +GRANT EXECUTE ON FUNCTION public.is_admin TO authenticated; +GRANT EXECUTE ON FUNCTION public.get_admin_role TO authenticated; +GRANT EXECUTE ON FUNCTION public.is_super_admin TO authenticated; diff --git a/apps/supabase/migrations/20251229100001_audit_log.sql b/apps/supabase/migrations/20251229100001_audit_log.sql new file mode 100644 index 0000000..9f687c6 --- /dev/null +++ b/apps/supabase/migrations/20251229100001_audit_log.sql @@ -0,0 +1,81 @@ +-- Audit Logging System +-- Creates audit_logs table and RPC function for logging admin actions + +-- Create action type enum for audit logging +CREATE TYPE public.audit_action AS ENUM ('INSERT', 'UPDATE', 'DELETE'); + +-- Audit log table +CREATE TABLE public.audit_logs ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + table_name TEXT NOT NULL, + record_id UUID NOT NULL, + action public.audit_action NOT NULL, + old_values JSONB, + new_values JSONB, + changed_fields TEXT[], + admin_user_id UUID NOT NULL REFERENCES auth.users(id), + admin_role public.admin_role NOT NULL, + created_at TIMESTAMPTZ DEFAULT NOW() +); + +-- Indexes for querying audit logs +CREATE INDEX idx_audit_logs_table_name ON public.audit_logs(table_name); +CREATE INDEX idx_audit_logs_record_id ON public.audit_logs(record_id); +CREATE INDEX idx_audit_logs_admin_user_id ON public.audit_logs(admin_user_id); +CREATE INDEX idx_audit_logs_created_at ON public.audit_logs(created_at DESC); +CREATE INDEX idx_audit_logs_action ON public.audit_logs(action); + +-- Enable RLS (super_admins only can view logs) +ALTER TABLE public.audit_logs ENABLE ROW LEVEL SECURITY; + +-- Policy: Only super admins can view audit logs +CREATE POLICY "Super admins can view audit logs" + ON public.audit_logs FOR SELECT + USING (public.is_super_admin()); + +-- Function to log admin actions (called from frontend via RPC) +CREATE OR REPLACE FUNCTION public.log_admin_action( + p_table_name TEXT, + p_record_id UUID, + p_action public.audit_action, + p_old_values JSONB DEFAULT NULL, + p_new_values JSONB DEFAULT NULL +) +RETURNS UUID AS $$ +DECLARE + log_id UUID; + admin_role_val public.admin_role; + changed_fields_arr TEXT[]; +BEGIN + -- Get admin role + SELECT role INTO admin_role_val FROM public.admin_users WHERE user_id = auth.uid(); + + IF admin_role_val IS NULL THEN + RAISE EXCEPTION 'User is not an admin'; + END IF; + + -- Calculate changed fields for updates + IF p_action = 'UPDATE' AND p_old_values IS NOT NULL AND p_new_values IS NOT NULL THEN + SELECT ARRAY( + SELECT key FROM jsonb_each(p_new_values) + WHERE p_old_values->key IS DISTINCT FROM p_new_values->key + ) INTO changed_fields_arr; + END IF; + + -- Insert audit log + INSERT INTO public.audit_logs ( + table_name, record_id, action, old_values, new_values, + changed_fields, admin_user_id, admin_role + ) + VALUES ( + p_table_name, p_record_id, p_action, p_old_values, p_new_values, + changed_fields_arr, auth.uid(), admin_role_val + ) + RETURNING id INTO log_id; + + RETURN log_id; +END; +$$ LANGUAGE plpgsql SECURITY DEFINER; + +-- Grant execute permission on logging function +GRANT EXECUTE ON FUNCTION public.log_admin_action TO authenticated; diff --git a/apps/supabase/migrations/20251229100002_admin_rls_policies.sql b/apps/supabase/migrations/20251229100002_admin_rls_policies.sql new file mode 100644 index 0000000..92d58b3 --- /dev/null +++ b/apps/supabase/migrations/20251229100002_admin_rls_policies.sql @@ -0,0 +1,79 @@ +-- Admin RLS Policies +-- Adds policies for admin access to tables + +-- Question Packs: All admins can manage (CRUD) +CREATE POLICY "Admins can view all question packs" + ON public.question_packs FOR SELECT + USING (public.is_admin()); + +CREATE POLICY "Admins can insert question packs" + ON public.question_packs FOR INSERT + WITH CHECK (public.is_admin()); + +CREATE POLICY "Admins can update question packs" + ON public.question_packs FOR UPDATE + USING (public.is_admin()); + +CREATE POLICY "Admins can delete question packs" + ON public.question_packs FOR DELETE + USING (public.is_admin()); + +-- Questions: All admins can manage (CRUD) +CREATE POLICY "Admins can view all questions" + ON public.questions FOR SELECT + USING (public.is_admin()); + +CREATE POLICY "Admins can insert questions" + ON public.questions FOR INSERT + WITH CHECK (public.is_admin()); + +CREATE POLICY "Admins can update questions" + ON public.questions FOR UPDATE + USING (public.is_admin()); + +CREATE POLICY "Admins can delete questions" + ON public.questions FOR DELETE + USING (public.is_admin()); + +-- Profiles: Super admins can view and update all +CREATE POLICY "Super admins can view all profiles" + ON public.profiles FOR SELECT + USING (public.is_super_admin()); + +CREATE POLICY "Super admins can update all profiles" + ON public.profiles FOR UPDATE + USING (public.is_super_admin()); + +-- Couples: Super admins can view all +CREATE POLICY "Super admins can view all couples" + ON public.couples FOR SELECT + USING (public.is_super_admin()); + +-- Responses: Super admins can view all +CREATE POLICY "Super admins can view all responses" + ON public.responses FOR SELECT + USING (public.is_super_admin()); + +-- Matches: Super admins can view all +CREATE POLICY "Super admins can view all matches" + ON public.matches FOR SELECT + USING (public.is_super_admin()); + +-- Messages: Super admins can view all +CREATE POLICY "Super admins can view all messages" + ON public.messages FOR SELECT + USING (public.is_super_admin()); + +-- Subscriptions: Super admins can view all +CREATE POLICY "Super admins can view all subscriptions" + ON public.subscriptions FOR SELECT + USING (public.is_super_admin()); + +-- Feedback: Super admins can view and manage all +CREATE POLICY "Super admins can view all feedback" + ON public.feedback FOR SELECT + USING (public.is_super_admin()); + +CREATE POLICY "Super admins can update all feedback" + ON public.feedback FOR UPDATE + USING (public.is_super_admin()); diff --git a/docs/authentication.md b/docs/authentication.md new file mode 100644 index 0000000..f6df836 --- /dev/null +++ b/docs/authentication.md @@ -0,0 +1,337 @@ +# Authentication System + +This document explains how authentication works in Sauci, including supported methods, session management, and deep linking. + +## Overview + +Sauci uses Supabase Auth with three authentication methods: +1. **Magic Link** (passwordless email) +2. **Email/Password** with verification +3. **Apple Sign-In** (iOS native + OAuth fallback) + +## Authentication Methods + +### Magic Link + +Passwordless authentication via email OTP. + +```typescript +const { error } = await supabase.auth.signInWithOtp({ + email: email.trim(), + options: { + emailRedirectTo: Linking.createURL("/(auth)/login"), + }, +}); +``` + +**Flow:** +1. User enters email +2. Supabase sends email with magic link +3. User taps link → Opens app via deep link +4. App extracts `token_hash` and verifies OTP +5. Session established + +### Email/Password + +Traditional email/password with email verification. + +**Sign Up:** +```typescript +const { data, error } = await supabase.auth.signUp({ + email: email.trim(), + password, +}); +``` + +**Sign In:** +```typescript +const { error } = await supabase.auth.signInWithPassword({ + email: email.trim(), + password, +}); +``` + +**Verification Flow:** +1. User signs up → Receives verification email +2. Shows "Verify your email" screen with: + - "I've verified my email" button (attempts sign-in) + - "Resend verification email" button (60s cooldown) +3. User clicks email link → Email verified +4. User taps "I've verified" → Sign-in succeeds + +**Resend Logic:** +```typescript +const { error } = await supabase.auth.resend({ + type: 'signup', + email: pendingVerification.email, +}); +// 60 second cooldown between resends +``` + +### Apple Sign-In + +Native Apple authentication on iOS with OAuth fallback. + +**Native (iOS with expo-apple-authentication):** +```typescript +const credential = await AppleAuthentication.signInAsync({ + requestedScopes: [ + AppleAuthenticationScope.FULL_NAME, + AppleAuthenticationScope.EMAIL, + ], +}); + +const { error } = await supabase.auth.signInWithIdToken({ + provider: "apple", + token: credential.identityToken, +}); +``` + +**OAuth Fallback (web, Expo Go):** +```typescript +const { error } = await supabase.auth.signInWithOAuth({ + provider: "apple", + options: { + redirectTo: Linking.createURL("/(auth)/login"), + }, +}); +``` + +## Session Management + +### Storage Adapter + +Sessions are stored in AsyncStorage (not SecureStore due to JWT size limits): + +```typescript +const ExpoStorageAdapter = { + getItem: async (key) => { + if (Platform.OS === "web") { + return window.localStorage.getItem(key); + } + return AsyncStorage.getItem(key); + }, + setItem: async (key, value) => { ... }, + removeItem: async (key) => { ... }, +}; + +export const supabase = createClient(url, key, { + auth: { + storage: ExpoStorageAdapter, + autoRefreshToken: true, + persistSession: true, + detectSessionInUrl: Platform.OS === "web", + }, +}); +``` + +### Auth State Listener + +The root layout listens for auth changes: + +```typescript +supabase.auth.onAuthStateChange(async (event, session) => { + if (event === "SIGNED_IN" || event === "TOKEN_REFRESHED") { + fetchUser(); + setUserContext(session.user.id, session.user.email); // Sentry + } else if (event === "SIGNED_OUT") { + await clearPushToken(userId); // Remove push token + setUser(null); + clearUserContext(); + queryClient.clear(); + } +}); +``` + +### Sign Out + +Sign out clears all app state: + +```typescript +signOut: async () => { + // Clear local state FIRST (ensures UI updates) + set({ user: null, couple: null, partner: null, ... }); + + // Clear other stores + useMatchStore.getState().clearMatches(); + usePacksStore.getState().clearPacks(); + useMessageStore.getState().clearMessages(); + useSubscriptionStore.getState().clearSubscription(); + + // Sign out from Supabase (don't block on this) + await supabase.auth.signOut(); +}, +``` + +## Deep Linking + +### URL Schemes + +| Platform | Scheme | Example | +|----------|--------|---------| +| iOS/Android | `sauci://` | `sauci://auth/callback` | +| Expo Dev | `exp://` | `exp://localhost:8081` | + +### Deep Link Handler + +The root layout processes auth deep links: + +```typescript +const handleDeepLink = async (url: string) => { + const parsedUrl = new URL(url); + const params = new URLSearchParams(parsedUrl.search); + const hashParams = new URLSearchParams(parsedUrl.hash.replace('#', '')); + + const accessToken = params.get('access_token') || hashParams.get('access_token'); + const refreshToken = params.get('refresh_token') || hashParams.get('refresh_token'); + const tokenHash = params.get('token_hash') || hashParams.get('token_hash'); + const type = params.get('type') || hashParams.get('type'); + + if (accessToken && refreshToken) { + // OAuth flow - set session directly + await supabase.auth.setSession({ access_token, refresh_token }); + } else if (tokenHash && type) { + // Magic link / email verification + await supabase.auth.verifyOtp({ token_hash, type }); + } +}; +``` + +### Deep Link Sources + +1. **Initial URL** - App opened via link (cold start) +2. **URL Event** - Link received while app running + +```typescript +// Cold start +Linking.getInitialURL().then(handleDeepLink); + +// App running +Linking.addEventListener("url", (event) => handleDeepLink(event.url)); +``` + +## Profile Auto-Creation + +Database trigger creates profile on signup: + +```sql +CREATE FUNCTION public.handle_new_user() +RETURNS TRIGGER AS $$ +BEGIN + INSERT INTO public.profiles (id, name, avatar_url) + VALUES ( + NEW.id, + COALESCE(NEW.raw_user_meta_data->>'name', + NEW.raw_user_meta_data->>'full_name'), + NEW.raw_user_meta_data->>'avatar_url' + ); + RETURN NEW; +END; +$$ LANGUAGE plpgsql SECURITY DEFINER; + +CREATE TRIGGER on_auth_user_created + AFTER INSERT ON auth.users + FOR EACH ROW EXECUTE FUNCTION public.handle_new_user(); +``` + +## Push Token Registration + +After authentication, push token is registered: + +```typescript +// In app layout after fetchUser +const token = await registerForPushNotificationsAsync(); +if (token && user.id) { + await savePushToken(user.id, token); +} +``` + +Tokens are stored in `profiles.push_token` and cleared on sign out. + +## Auth Flow Diagram + +``` +┌─────────────────────────────────────────────────────────────┐ +│ Login Screen │ +│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │ +│ │ Magic Link │ │ Password │ │ Apple │ │ +│ └──────────────┘ └──────────────┘ └──────────────┘ │ +└─────────────────────────────────────────────────────────────┘ + │ │ │ + ▼ ▼ ▼ +┌────────────────┐ ┌────────────────┐ ┌────────────────┐ +│ signInWithOtp │ │ signUp/signIn │ │ signInWithId │ +│ │ │ WithPassword │ │ Token (native) │ +└────────────────┘ └────────────────┘ └────────────────┘ + │ │ │ + ▼ ▼ ▼ +┌────────────────┐ ┌────────────────┐ ┌────────────────┐ +│ Email sent │ │ Verify email │ │ ID Token │ +│ (magic link) │ │ (if signup) │ │ validated │ +└────────────────┘ └────────────────┘ └────────────────┘ + │ │ │ + └──────────┬─────────┴────────────────────┘ + ▼ + ┌────────────────────┐ + │ onAuthStateChange │ + │ event: SIGNED_IN │ + └──────────┬─────────┘ + ▼ + ┌────────────────────┐ + │ fetchUser() │ + │ Load profile from │ + │ database │ + └──────────┬─────────┘ + ▼ + ┌────────────────────┐ + │ Register push │ + │ token if granted │ + └──────────┬─────────┘ + ▼ + ┌────────────────────┐ + │ Navigate to app │ + │ (or pairing if │ + │ no partner) │ + └────────────────────┘ +``` + +## Supabase Auth Config + +From `apps/supabase/config.toml`: + +```toml +[auth] +enabled = true +site_url = "exp://localhost:8081" +additional_redirect_urls = ["sauci://auth/callback"] +jwt_expiry = 3600 # 1 hour +enable_refresh_token_rotation = true +refresh_token_reuse_interval = 10 +enable_signup = true + +[auth.external.apple] +enabled = true +client_id = "env(APPLE_CLIENT_ID)" +secret = "env(APPLE_CLIENT_SECRET)" + +[auth.email] +enable_signup = true +double_confirm_changes = true +enable_confirmations = false # Email verification disabled by default +``` + +## Error Handling + +| Error | Cause | Handling | +|-------|-------|----------| +| "Email not confirmed" | Sign-in before verification | Show verification pending screen | +| "Invalid login credentials" | Wrong password | Show error message | +| Empty identities array | Email already exists (signup) | Prompt to sign in instead | +| "ERR_REQUEST_CANCELED" | User canceled Apple sign-in | Silent (no error shown) | + +## Security Considerations + +- JWTs stored in AsyncStorage (encrypted on iOS, less secure on Android) +- Push tokens cleared on sign out +- Session auto-refresh enabled +- OAuth state verified via deep link parameters diff --git a/docs/couple-pairing.md b/docs/couple-pairing.md new file mode 100644 index 0000000..536d1b0 --- /dev/null +++ b/docs/couple-pairing.md @@ -0,0 +1,287 @@ +# Couple Pairing System + +This document explains how couples are created, joined, and managed in Sauci. + +## Overview + +Couples are the core relationship unit in Sauci. Two users link via an invite code to form a couple, enabling shared question answering and match detection. + +## States + +A user can be in one of three states: + +| State | `couple_id` | `partner` | Description | +|-------|-------------|-----------|-------------| +| **Unpaired** | `null` | `null` | Not in any couple | +| **Waiting** | Set | `null` | Created couple, waiting for partner | +| **Paired** | Set | Exists | Both partners joined | + +## Edge Functions + +### `manage-couple` (POST) + +Handles creating and joining couples. + +**Create Couple** (empty body): +```javascript +// Request +{ } + +// Response +{ success: true, couple_id: "uuid", invite_code: "abc12345" } +``` + +**Join Couple** (with invite code): +```javascript +// Request +{ invite_code: "abc12345" } + +// Response +{ success: true, couple_id: "uuid" } +``` + +**Validations:** +- User cannot already be in a couple +- Invite code must exist +- Target couple must have < 2 members + +### `manage-couple` (DELETE) + +Leaves current couple (sets `couple_id` to null). + +```javascript +// Response +{ success: true } +``` + +### `delete-relationship` (DELETE) + +**Destructive** - Completely deletes the couple and all associated data. + +**What Gets Deleted:** +1. Chat media from storage (iterates through all match folders) +2. Both partners' `couple_id` set to `null` +3. Couple record deleted (cascades to): + - All responses + - All matches + - All messages + - All couple_packs settings + +## Database Schema + +### Couple Record + +```sql +CREATE TABLE public.couples ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + invite_code TEXT UNIQUE DEFAULT substr(md5(random()::text), 1, 8), + created_at TIMESTAMPTZ DEFAULT NOW() +); +``` + +**Invite Code:** +- 8 character hex string (e.g., `a1b2c3d4`) +- Auto-generated on insert +- Case-insensitive matching (lowercased on lookup) + +### Profile Link + +```sql +-- profiles table +couple_id UUID REFERENCES couples(id) ON DELETE SET NULL +``` + +When a couple is deleted, both profiles automatically have `couple_id` set to `null` (preserving accounts). + +### Size Enforcement + +Database trigger prevents > 2 members per couple: + +```sql +CREATE TRIGGER enforce_couple_size +BEFORE INSERT OR UPDATE OF couple_id ON profiles +FOR EACH ROW EXECUTE FUNCTION check_couple_size(); + +-- Raises exception if count >= 2 +``` + +This prevents race conditions where multiple users try to join simultaneously. + +## Client Flow + +### Pairing Screen (`app/(app)/pairing.tsx`) + +**Unpaired State:** +``` +┌─────────────────────────────┐ +│ 🔗 Pair Up │ +│ │ +│ ┌───────────────────────┐ │ +│ │ Have a code? │ │ +│ │ [Enter invite code] │ │ +│ │ [Join Partner] │ │ +│ └───────────────────────┘ │ +│ │ +│ ── or ── │ +│ │ +│ [Create New Code] │ +└─────────────────────────────┘ +``` + +**Waiting State:** +``` +┌─────────────────────────────┐ +│ ❤️ Partner Code │ +│ │ +│ Share this code with │ +│ your partner │ +│ │ +│ ┌───────────────────┐ │ +│ │ ABC12345 📋 │ │ +│ └───────────────────┘ │ +│ │ +│ [Share Code] │ +│ │ +│ Waiting for partner... │ +│ ⏳ │ +└─────────────────────────────┘ +``` + +### Real-Time Partner Detection + +When waiting for partner, the screen listens for updates: + +```typescript +// Polling fallback every 5 seconds +const pollInterval = setInterval(() => { + fetchCouple(); +}, 5000); + +// Real-time subscription +const subscription = supabase + .channel(`couple-${couple.id}`) + .on("postgres_changes", { + event: "UPDATE", + schema: "public", + table: "profiles", + filter: `couple_id=eq.${couple.id}`, + }, async (payload) => { + if (payload.new.id !== user?.id) { + await fetchCouple(); + } + }) + .subscribe(); +``` + +### Auto-Redirect + +When partner is detected, user is redirected: + +```typescript +useEffect(() => { + if (couple && partner) { + router.replace("/(app)/"); + } +}, [couple, partner]); +``` + +## Flow Diagrams + +### Create & Share Flow + +``` +User A: Unpaired + │ + ▼ +[Create New Code] + │ + ▼ +POST manage-couple (empty body) + │ + ├──▶ Create couple record + │ (generates invite_code) + │ + ├──▶ Set user A's couple_id + │ + ▼ +User A: Waiting + │ + ├──▶ Display invite code + │ + ├──▶ [Share Code] → Native share sheet + │ + └──▶ Subscribe to realtime + poll +``` + +### Join Flow + +``` +User B: Unpaired + │ + ▼ +[Enter Code] → "abc12345" + │ + ▼ +POST manage-couple { invite_code: "abc12345" } + │ + ├──▶ Validate code exists + │ + ├──▶ Check couple has < 2 members + │ + ├──▶ Set user B's couple_id + │ + ▼ +User B: Paired + │ + ├──▶ Realtime event fires + │ + ▼ +User A: Receives update → Paired +``` + +### Delete Relationship Flow + +``` +User: Profile → Settings → Delete Relationship + │ + ▼ +Confirmation Dialog + │ + ▼ +DELETE delete-relationship + │ + ├──▶ List all matches for couple + │ + ├──▶ Delete chat-media storage files + │ + ├──▶ Set both profiles.couple_id = null + │ + ├──▶ DELETE couple record + │ (cascades to responses, matches, messages, couple_packs) + │ + ▼ +Both users: Unpaired +``` + +## Cascade Behavior + +When a couple is deleted, foreign key cascades clean up: + +| Table | Action | +|-------|--------| +| `profiles` | `couple_id` set to `null` | +| `responses` | Rows deleted | +| `matches` | Rows deleted | +| `messages` | Rows deleted | +| `couple_packs` | Rows deleted | + +Storage (chat-media) is manually cleaned before the cascade. + +## Error Handling + +| Error | Cause | Message | +|-------|-------|---------| +| Already paired | User tries to create/join while in couple | "You are already in a couple" | +| Invalid code | Invite code doesn't exist | "Invalid invite code" | +| Couple full | Couple already has 2 members | "This couple already has two partners" | +| DB trigger | Race condition caught | "A couple can only have 2 members" | diff --git a/docs/match-notifications.md b/docs/match-notifications.md new file mode 100644 index 0000000..5cf23c6 --- /dev/null +++ b/docs/match-notifications.md @@ -0,0 +1,293 @@ +# Match & Notification System + +This document explains how matches are detected, stored, and how push notifications are triggered. + +## Overview + +A **match** occurs when both partners in a couple answer a question positively (not "no"). Matches unlock a chat thread for discussing that topic. + +## Match Types + +| Type | Partner A | Partner B | Description | +|------|-----------|-----------|-------------| +| `yes_yes` | Yes | Yes | Both enthusiastic | +| `yes_maybe` | Yes | Maybe | One enthusiastic, one open | +| `maybe_maybe` | Maybe | Maybe | Both open to exploring | + +**No Match Cases:** +- Either partner answers "No" → No match created +- Only one partner has answered → No match yet (waiting) + +## Match Detection Flow + +``` +┌─────────────────────────────────────────────────────────────┐ +│ submit-response Edge Function │ +└─────────────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────┐ +│ 1. Validate user JWT │ +│ 2. Get user's couple_id from profiles │ +│ 3. UPSERT response (user_id, question_id, answer) │ +└─────────────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────┐ +│ 4. Query partner's response for same question │ +│ SELECT answer FROM responses │ +│ WHERE couple_id = ? AND question_id = ? AND user_id != ? │ +└─────────────────────────────────────────────────────────────┘ + │ + ┌───────────────┴───────────────┐ + ▼ ▼ + Partner answered? No partner response + │ │ + ▼ ▼ + calculateMatch(a1, a2) Return { response } + │ (no match yet) + ▼ +┌─────────────────────────────────────────────────────────────┐ +│ function calculateMatch(a1, a2): │ +│ if (a1 === "no" || a2 === "no") return null │ +│ if (a1 === "yes" && a2 === "yes") return "yes_yes" │ +│ if (a1 === "maybe" && a2 === "maybe") return "maybe_maybe"│ +│ return "yes_maybe" │ +└─────────────────────────────────────────────────────────────┘ + │ + ┌───────────────┴───────────────┐ + ▼ ▼ + Match type found null (no match) + │ │ + ▼ ▼ +┌──────────────────────────┐ Return { response } +│ UPSERT match record │ +│ (couple_id, question_id) │ +└────────────┬─────────────┘ + │ + ▼ +┌──────────────────────────┐ +│ Invoke send-notification │ +│ { couple_id, match_id } │ +└────────────┬─────────────┘ + │ + ▼ + Return { response, match } +``` + +## Match Storage + +```sql +CREATE TABLE public.matches ( + id UUID PRIMARY KEY, + couple_id UUID NOT NULL REFERENCES couples(id), + question_id UUID NOT NULL REFERENCES questions(id), + match_type match_type NOT NULL, -- yes_yes, yes_maybe, maybe_maybe + is_new BOOLEAN DEFAULT TRUE, -- For notification badge + created_at TIMESTAMPTZ DEFAULT NOW(), + UNIQUE(couple_id, question_id) -- One match per question per couple +); +``` + +**UPSERT Behavior:** +Matches use `onConflict: "couple_id,question_id"` so if a user changes their answer, the match type updates rather than creating duplicates. + +## Push Notifications + +### Match Notification (`send-notification`) + +Triggered when a new match is created. + +**Input:** +```json +{ "couple_id": "uuid", "match_id": "uuid" } +``` + +**Behavior:** +1. Fetch all profiles in couple with push tokens +2. Build notification for both partners: + ```javascript + { + to: profile.push_token, + title: "It's a match! 💕", + body: "You and your partner matched on something new!", + sound: "default", + data: { match_id, type: "match" } + } + ``` +3. Send via Expo Push API + +**Privacy:** Notification content is generic - doesn't reveal the question topic. + +### Message Notification (`send-message-notification`) + +Triggered when a chat message is sent. + +**Input:** +```json +{ "match_id": "uuid", "sender_id": "uuid" } +``` + +**Behavior:** +1. Find the match's couple_id +2. Get recipient's push token (partner, not sender) +3. Build notification: + ```javascript + { + to: recipient.push_token, + title: "New message", + body: "Your partner sent you a message", + sound: "default", + data: { type: "message", match_id, message_id } + } + ``` +4. Send via Expo Push API + +**Privacy:** Message content not included in notification. + +## Client State Management + +### Match Store (`src/store/index.ts`) + +```typescript +interface MatchState { + matches: Match[]; + newMatchesCount: number; + fetchMatches: () => Promise; + markAsSeen: (matchId: string) => Promise; + markAllAsSeen: () => Promise; + addMatch: (match: Match) => void; + clearMatches: () => void; +} +``` + +### Fetching Matches + +The `fetchMatches` function: +1. Queries matches with joined question data +2. Fetches all responses to determine who answered first (for two-part display) +3. Counts unread messages per match +4. Calculates new matches count (where `is_new = true`) + +```typescript +const { data: matches } = await supabase + .from("matches") + .select(`*, question:questions(*)`) + .order("created_at", { ascending: false }); +``` + +### Match Display + +The matches screen shows: +- Question text (user's version for two-part questions) +- Partner's question text (if two-part) +- Match type badge (`YES + YES` or `YES + MAYBE`) +- Unread message indicator +- Date + +**Two-Part Question Display Logic:** +```typescript +// Determine which text each person saw based on response timestamps +if (userResponse.created_at > partnerResponse.created_at) { + userText = question.partner_text; // User was second + partnerText = question.text; // Partner was first +} +``` + +## Chat System + +Each match unlocks a chat thread stored in `messages` table: + +```sql +CREATE TABLE public.messages ( + id UUID PRIMARY KEY, + match_id UUID NOT NULL REFERENCES matches(id), + user_id UUID NOT NULL REFERENCES auth.users(id), + content TEXT, + media_path TEXT, -- Storage path for images + read_at TIMESTAMPTZ, + created_at TIMESTAMPTZ, + CONSTRAINT content_or_media CHECK (content IS NOT NULL OR media_path IS NOT NULL) +); +``` + +### Real-Time Chat + +Messages use Supabase Realtime: +```sql +ALTER PUBLICATION supabase_realtime ADD TABLE messages; +``` + +### Message Read Tracking + +- `read_at` is `NULL` for unread messages +- Updated when recipient opens chat thread +- Used to calculate unread counts and badges + +## Notification Flow Diagram + +``` +User A swipes "Yes" + │ + ▼ +submit-response + │ + ├──▶ Save response + │ + ├──▶ Check partner response + │ │ + │ ▼ + │ Partner said "Yes"? + │ │ + │ ▼ + │ Create match (yes_yes) + │ │ + └─────────┼──▶ Invoke send-notification + │ + ▼ + ┌─────────────────────┐ + │ Expo Push Service │ + └─────────────────────┘ + │ + ┌─────────┴─────────┐ + ▼ ▼ + User A Phone User B Phone + "It's a match!" "It's a match!" +``` + +## RLS Policies + +Users can only see matches for their couple: + +```sql +CREATE POLICY "Users can view their couple matches" + ON public.matches FOR SELECT + USING ( + couple_id IN ( + SELECT couple_id FROM profiles WHERE id = auth.uid() + ) + ); +``` + +## Badge Management + +### New Match Badge + +- Set to `is_new = TRUE` on match creation +- Updated to `FALSE` when user views matches screen +- Count displayed on tab bar badge + +```typescript +// On matches screen focus +useEffect(() => { + if (matches.length > 0) { + markAllAsSeen(); // Clears is_new flags + } +}, [matches.length]); +``` + +### Unread Message Badge + +- Counted per match from messages where `read_at IS NULL` +- Displayed as bubble on match card +- Cleared when entering chat thread diff --git a/docs/question-selection.md b/docs/question-selection.md new file mode 100644 index 0000000..1e6b630 --- /dev/null +++ b/docs/question-selection.md @@ -0,0 +1,154 @@ +# Question Selection Algorithm + +This document explains how questions are selected and ordered for users in the swipe interface. + +## Overview + +Question selection happens in two stages: +1. **Database Layer** - `get_recommended_questions()` PostgreSQL function filters available questions +2. **Client Layer** - `swipe.tsx` applies additional filtering and sorting with bias + +## Stage 1: Database Filtering + +The `get_recommended_questions(target_pack_id)` function (`apps/supabase/migrations/20251227172500_role_specific_questions.sql`) returns questions the user hasn't answered yet. + +### Pack Selection Logic + +``` +IF target_pack_id provided: + → Use only that specific pack +ELSE IF couple has any couple_packs entries: + → Use only packs where enabled = true +ELSE (new couple, no preferences set): + → Use all public packs (is_public = true) +``` + +### Question Filtering + +For each question in active packs: +- **Excluded**: Questions the current user has already answered +- **Included**: All unanswered questions from active packs + +### Returned Fields + +| Field | Description | +|-------|-------------| +| `id` | Question UUID | +| `text` | Display text (see two-part logic below) | +| `partner_text` | Original partner text (for reference) | +| `is_two_part` | `true` if question has different text for second responder | +| `pack_id` | Parent pack UUID | +| `intensity` | 1-5 rating | +| `partner_answered` | `true` if partner has already responded | + +### Two-Part Questions + +Some questions have role-specific text (`partner_text` column). The function handles this: + +``` +IF partner_text EXISTS AND partner has answered: + → Return partner_text as display text +ELSE: + → Return original text +``` + +This enables questions like: +- First person sees: "Would you like your partner to wake you up with oral?" +- Second person sees: "Would you wake your partner up with oral?" + +## Stage 2: Client Sorting & Filtering + +Located in `apps/mobile/app/(app)/swipe.tsx`, lines 57-95. + +### Skip Filtering + +Before sorting, questions are filtered against locally-skipped questions: + +```typescript +const filtered = data.filter(q => !skippedIds.has(q.id)); +``` + +**Skip behavior** (`src/lib/skippedQuestions.ts`): +- Swipe down = skip question +- Skipped questions hidden for **24 hours** +- Stored in SecureStore (native) or localStorage (web) +- No server-side record - purely client-side convenience + +### Sorting Algorithm with Bias + +Questions are sorted by a computed score: + +```typescript +const sorted = filtered.sort((a, b) => { + let scoreA = Math.random(); // Base: 0.0 - 1.0 + let scoreB = Math.random(); + + // Apply bias bonuses + if (a.partner_answered && a.is_two_part) scoreA += 1.5; + else if (a.partner_answered) scoreA += 0.7; + else if (a.is_two_part) scoreA += 0.4; + + // Same for scoreB... + + return scoreB - scoreA; // Descending order +}); +``` + +### Priority Tiers + +| Priority | Condition | Score Range | Rationale | +|----------|-----------|-------------|-----------| +| **1st** | `partner_answered` + `is_two_part` | 1.5 - 2.5 | Completes two-part question, immediate match potential | +| **2nd** | `partner_answered` only | 0.7 - 1.7 | High match potential, partner waiting | +| **3rd** | `is_two_part` only | 0.4 - 1.4 | Initiates two-part sequence | +| **4th** | Neither | 0.0 - 1.0 | Standard questions, fully randomized | + +### Why This Bias? + +1. **Engagement**: Questions partner already answered can immediately create matches, providing instant gratification + +2. **Two-part completion**: When partner answered a two-part question first, completing it quickly maintains context and creates better matches + +3. **Two-part initiation**: Slightly prioritizes two-part questions to create more engaging interactions + +4. **Randomization within tiers**: The random base prevents predictable ordering while maintaining priority bands + +## Visual Flow + +``` +┌─────────────────────────────────────────────────────────────┐ +│ get_recommended_questions() │ +│ ┌─────────────┐ ┌──────────────┐ ┌───────────────┐ │ +│ │ Pack Filter │ → │ User Answered │ → │ Two-Part Text │ │ +│ │ │ │ Exclusion │ │ Resolution │ │ +│ └─────────────┘ └──────────────┘ └───────────────┘ │ +└─────────────────────────────────────────────────────────────┘ + ↓ +┌─────────────────────────────────────────────────────────────┐ +│ Client (swipe.tsx) │ +│ ┌─────────────┐ ┌──────────────┐ ┌───────────────┐ │ +│ │ Skip Filter │ → │ Bias Scoring │ → │ Sort │ │ +│ │ (24 hour) │ │ │ │ (Descending) │ │ +│ └─────────────┘ └──────────────┘ └───────────────┘ │ +└─────────────────────────────────────────────────────────────┘ + ↓ + Questions shown to user +``` + +## Edge Cases + +| Scenario | Behavior | +|----------|----------| +| No packs enabled | Shows "No packs enabled" prompt | +| All questions answered | Shows "All caught up!" with refresh button | +| No partner paired | Shows pairing prompt | +| Pack disabled mid-session | Client filters out questions from disabled packs | + +## Configuration + +| Setting | Value | Location | +|---------|-------|----------| +| Skip duration | 24 hours | `src/lib/skippedQuestions.ts:6` | +| Two-part + answered bias | +1.5 | `swipe.tsx:78` | +| Answered-only bias | +0.7 | `swipe.tsx:79` | +| Two-part-only bias | +0.4 | `swipe.tsx:80` | diff --git a/docs/schema.md b/docs/schema.md new file mode 100644 index 0000000..2cd0bf8 --- /dev/null +++ b/docs/schema.md @@ -0,0 +1,182 @@ +# Database Schema + +## Entity Relationship Diagram + +```mermaid +erDiagram + %% Core Authentication + auth_users { + uuid id PK + string email + jsonb raw_user_meta_data + } + + %% User Management + profiles { + uuid id PK,FK "references auth.users" + text name + text avatar_url + text push_token + boolean is_premium + uuid couple_id FK + timestamptz created_at + timestamptz updated_at + } + + couples { + uuid id PK + text invite_code UK + timestamptz created_at + } + + %% Content + question_packs { + uuid id PK + text name + text description + text icon + boolean is_premium + boolean is_public + int sort_order + timestamptz created_at + } + + questions { + uuid id PK + uuid pack_id FK + text text + text partner_text "for two-part questions" + int intensity "1-5" + timestamptz created_at + } + + %% Couple Pack Selection + couple_packs { + uuid couple_id PK,FK + uuid pack_id PK,FK + boolean enabled + timestamptz created_at + } + + %% User Interaction + responses { + uuid id PK + uuid user_id FK + uuid question_id FK + uuid couple_id FK + answer_type answer "yes/no/maybe" + timestamptz created_at + } + + matches { + uuid id PK + uuid couple_id FK + uuid question_id FK + match_type match_type "yes_yes/yes_maybe/maybe_maybe" + boolean is_new + timestamptz created_at + } + + messages { + uuid id PK + uuid match_id FK + uuid user_id FK + text content + text media_path + timestamptz read_at + timestamptz created_at + } + + %% Subscriptions + subscriptions { + uuid id PK + uuid user_id FK + text revenuecat_app_user_id + text product_id + subscription_status status + text[] entitlement_ids + timestamptz purchased_at + timestamptz expires_at + text original_transaction_id + text store + boolean is_sandbox + text cancel_reason + timestamptz grace_period_expires_at + timestamptz created_at + timestamptz updated_at + } + + revenuecat_webhook_events { + uuid id PK + text event_id UK + text event_type + text app_user_id + timestamptz processed_at + jsonb payload + } + + %% Feedback + feedback { + uuid id PK + uuid user_id FK + feedback_type type "bug/feature_request/general" + text title + text description + text screenshot_url + jsonb device_info + feedback_status status + text admin_notes + timestamptz created_at + timestamptz updated_at + } + + %% Relationships + auth_users ||--|| profiles : "extends" + couples ||--o{ profiles : "has members" + couples ||--o{ couple_packs : "selects packs" + couples ||--o{ responses : "has responses" + couples ||--o{ matches : "has matches" + + question_packs ||--o{ questions : "contains" + question_packs ||--o{ couple_packs : "selected by" + + questions ||--o{ responses : "answered by" + questions ||--o{ matches : "generates" + + profiles ||--o{ responses : "submits" + profiles ||--o{ feedback : "submits" + profiles ||--o{ subscriptions : "has" + + matches ||--o{ messages : "has chat" + auth_users ||--o{ messages : "sends" +``` + +## Enums + +| Enum | Values | +|------|--------| +| `answer_type` | `yes`, `no`, `maybe` | +| `match_type` | `yes_yes`, `yes_maybe`, `maybe_maybe` | +| `feedback_type` | `bug`, `feature_request`, `general` | +| `feedback_status` | `new`, `reviewed`, `in_progress`, `resolved`, `closed` | +| `subscription_status` | `active`, `cancelled`, `expired`, `billing_issue`, `paused` | + +## Key Flows + +### Response & Match Flow +1. User submits response via `submit-response` edge function +2. Response saved to `responses` table (UPSERT on `user_id, question_id`) +3. Function checks for partner's response on same question +4. If both answered positively (not "no"), a `match` is created +5. Match triggers push notification to both partners +6. Match unlocks chat thread in `messages` table + +### Couple Pairing Flow +1. First user creates couple, gets `invite_code` +2. Second user joins via `manage-couple` edge function with invite code +3. Both profiles' `couple_id` set to same couple + +### Pack Selection Flow +1. Couples can enable/disable packs via `couple_packs` junction table +2. `get_recommended_questions()` function filters questions by enabled packs +3. Two-part questions show `partner_text` to second responder diff --git a/docs/subscription-system.md b/docs/subscription-system.md new file mode 100644 index 0000000..23b5136 --- /dev/null +++ b/docs/subscription-system.md @@ -0,0 +1,277 @@ +# Subscription & Premium System + +This document explains how subscriptions and premium access work across the client, server, and database layers. + +## Overview + +Sauci uses RevenueCat for subscription management with a multi-layer architecture: + +``` +┌─────────────────┐ ┌──────────────────┐ ┌─────────────────┐ +│ Mobile App │────▶│ RevenueCat │────▶│ Supabase Edge │ +│ (RevenueCat │ │ Service │ │ Functions │ +│ SDK) │◀────│ │◀────│ │ +└─────────────────┘ └──────────────────┘ └─────────────────┘ + │ + ▼ + ┌─────────────────┐ + │ Database │ + │ (is_premium) │ + └─────────────────┘ +``` + +## Components + +### 1. Client SDK (`src/lib/revenuecat.ts`) + +The `RevenueCatService` class wraps the `react-native-purchases` SDK. + +**Platform Support:** +- iOS: Full support with native SDK +- Android: Not yet implemented +- Web/Expo Go: Gracefully disabled (returns defaults) + +**Key Methods:** + +| Method | Purpose | +|--------|---------| +| `initialize(userId)` | Configure SDK with API key and user ID | +| `login(userId)` | Associate purchases with Supabase user | +| `getOfferings()` | Fetch available subscription packages | +| `purchasePackage(pkg)` | Initiate purchase flow | +| `restorePurchases()` | Restore previous purchases | +| `getCustomerInfo()` | Get current subscription status | +| `parseCustomerInfo()` | Convert to `SubscriptionState` | + +**Entitlement Check:** +```typescript +const entitlement = customerInfo.entitlements.active[ENTITLEMENT_ID]; +return { + isProUser: !!entitlement, + activeSubscription: entitlement?.productIdentifier || null, + expirationDate: entitlement?.expirationDate ? new Date(...) : null, + willRenew: entitlement?.willRenew || false, +}; +``` + +### 2. Zustand Store (`src/store/index.ts`) + +The `useSubscriptionStore` manages subscription state in the app: + +```typescript +interface SubscriptionStoreState { + subscription: SubscriptionState; + offerings: PurchasesOffering | null; + isLoadingOfferings: boolean; + isPurchasing: boolean; + error: string | null; +} +``` + +**Key Actions:** +- `initializeRevenueCat(userId)` - Called after auth, sets up SDK +- `fetchOfferings()` - Loads available packages for paywall +- `purchasePackage(pkg)` - Handles purchase with error handling +- `restorePurchases()` - Restores and syncs to server +- `refreshSubscriptionStatus()` - Checks RevenueCat and syncs to server + +### 3. Webhook Handler (`functions/revenuecat-webhook`) + +RevenueCat sends webhook events for subscription lifecycle changes. + +**Event Types Handled:** + +| Event | Status Set | +|-------|------------| +| `INITIAL_PURCHASE` | `active` | +| `RENEWAL` | `active` | +| `UNCANCELLATION` | `active` | +| `PRODUCT_CHANGE` | `active` | +| `CANCELLATION` | `cancelled` | +| `EXPIRATION` | `expired` | +| `BILLING_ISSUE` | `billing_issue` | +| `SUBSCRIPTION_PAUSED` | `paused` | + +**Idempotency:** +Events are logged to `revenuecat_webhook_events` table with unique `event_id` to prevent duplicate processing. + +**Flow:** +``` +1. Verify webhook secret (Authorization header) +2. Check for duplicate event_id +3. Record event in webhook_events table +4. Upsert subscription record +5. Database trigger syncs is_premium to profiles +``` + +### 4. Sync Function (`functions/sync-subscription`) + +Called from client to verify subscription status server-side. + +**When Called:** +- After purchase completes +- On app foreground (if status differs) +- Manual restore purchases + +**Flow:** +``` +1. Verify user JWT +2. Call RevenueCat API: GET /v1/subscribers/{user_id} +3. Check entitlement expiration date +4. Update profiles.is_premium +``` + +### 5. Database Layer + +**Tables:** + +```sql +-- Main subscription record +subscriptions ( + user_id UUID FK profiles, + product_id TEXT, + status subscription_status, -- active/cancelled/expired/billing_issue/paused + expires_at TIMESTAMPTZ, + ... +) + +-- Idempotency tracking +revenuecat_webhook_events ( + event_id TEXT UNIQUE, + event_type TEXT, + app_user_id TEXT, + payload JSONB +) +``` + +**Auto-Sync Trigger:** +```sql +CREATE TRIGGER sync_premium_on_subscription_change + AFTER INSERT OR UPDATE ON public.subscriptions + FOR EACH ROW EXECUTE FUNCTION public.sync_premium_status(); +``` + +This trigger updates `profiles.is_premium` based on active subscription status. + +## Premium Access Logic + +### Partner Premium Sharing + +If either partner in a couple has premium, both get access: + +```sql +CREATE FUNCTION public.has_premium_access(check_user_id UUID) +RETURNS BOOLEAN AS $$ + -- Returns TRUE if user OR their partner has is_premium = TRUE +$$; +``` + +### RLS Policies + +Premium packs are filtered at the database level: + +```sql +CREATE POLICY "Users or partners with premium can view premium packs" + ON public.question_packs FOR SELECT + USING ( + is_premium = FALSE OR + EXISTS ( + SELECT 1 FROM profiles p WHERE p.id = auth.uid() + AND (p.is_premium = TRUE OR EXISTS ( + SELECT 1 FROM profiles partner + WHERE partner.couple_id = p.couple_id + AND partner.is_premium = TRUE + )) + ) + ); +``` + +### Question Filtering + +The `get_recommended_questions()` function also filters premium packs: + +```sql +-- Only include packs where: +(qp.is_premium = FALSE OR v_has_premium = TRUE) +``` + +## Environment Variables + +| Variable | Location | Purpose | +|----------|----------|---------| +| `EXPO_PUBLIC_REVENUECAT_IOS_API_KEY` | Mobile app | RevenueCat public SDK key | +| `EXPO_PUBLIC_REVENUECAT_ENTITLEMENT_ID` | Mobile app | Entitlement identifier (default: "pro") | +| `REVENUECAT_API_KEY` | Edge functions | RevenueCat secret API key | +| `REVENUECAT_ENTITLEMENT_ID` | Edge functions | Server-side entitlement check | +| `REVENUECAT_WEBHOOK_SECRET` | Edge functions | Webhook auth verification | + +## Subscription Flow Diagrams + +### Purchase Flow + +``` +User taps Subscribe + │ + ▼ +┌───────────────────┐ +│ fetchOfferings() │ +└─────────┬─────────┘ + ▼ +┌───────────────────┐ +│ Display Paywall │ +└─────────┬─────────┘ + ▼ +┌───────────────────┐ +│ purchasePackage() │──▶ Native App Store UI +└─────────┬─────────┘ + ▼ +┌───────────────────┐ +│ RevenueCat SDK │──▶ Sends webhook to server +│ returns success │ +└─────────┬─────────┘ + ▼ +┌───────────────────┐ +│ refreshStatus() │──▶ Calls sync-subscription +└─────────┬─────────┘ + ▼ +┌───────────────────┐ +│ fetchUser() │──▶ UI shows premium features +└───────────────────┘ +``` + +### Webhook Flow + +``` +RevenueCat Event (e.g., RENEWAL) + │ + ▼ +┌───────────────────────┐ +│ revenuecat-webhook │ +│ Edge Function │ +└─────────┬─────────────┘ + │ + ├──▶ Check event_id (idempotency) + │ + ├──▶ Record in webhook_events + │ + ▼ +┌───────────────────────┐ +│ UPSERT subscriptions │ +└─────────┬─────────────┘ + │ + ▼ +┌───────────────────────┐ +│ sync_premium_status() │──▶ Updates profiles.is_premium +│ (Database Trigger) │ +└───────────────────────┘ +``` + +## Testing + +RevenueCat sandbox mode is detected via `event.environment !== "PRODUCTION"` and stored in `subscriptions.is_sandbox`. + +To test webhooks locally: +```bash +# Use ngrok or similar to expose local Supabase +supabase functions serve revenuecat-webhook --env-file .env.local +```