This commit is contained in:
jjbrunton committed 2025-12-29 23:10:12 +00:00
1 parent 358c2be0ee
commit b10eae1bf3
26 files changed
+2488 -32

No files matched your search

+7
View File
@@ -0,0 +1,7 @@
{
"permissions": {
"allow": [
"Bash(wc:*)"
]
}
}
+14
View File
@@ -0,0 +1,14 @@
node_modules
.git
.gitignore
*.md
.env*
.DS_Store
dist
.expo
android
ios
*.log
.turbo
coverage
.nyc_output
+90
View File
@@ -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 <name> # 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
+20
View File
@@ -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"]
+16
View File
@@ -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
+51 -5
View File
@@ -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 = () => (
<Admin dataProvider={dataProvider} authProvider={authProvider} requireAuth>
<Admin
dataProvider={auditDataProvider}
authProvider={authProvider}
requireAuth
>
{(permissions: AdminPermissions) => (
<>
{/* Question Packs - accessible to all admins */}
<Resource
name="question_packs"
list={PackList}
edit={PackEdit}
create={PackCreate}
recordRepresentation="name"
options={{ label: 'Question Packs' }}
/>
{/* Questions - accessible to all admins */}
<Resource
name="questions"
list={QuestionList}
edit={QuestionEdit}
create={QuestionCreate}
options={{ label: 'Questions' }}
/>
{/* Super admin only resources */}
{permissions?.role === 'super_admin' && (
<>
<Resource
name="profiles"
list={ProfileList}
show={ProfileShow}
edit={ProfileEdit}
recordRepresentation="name"
options={{ label: 'Users' }}
/>
<Resource
name="couples"
list={CoupleList}
options={{ label: 'Relationships' }}
/>
<Resource
name="matches"
list={MatchList}
show={MatchShow}
options={{ label: 'Matches & Chats' }}
/>
<Resource
name="responses"
list={ResponseList}
options={{ label: 'Responses' }}
/>
<Resource name="couples" />
<Resource
name="messages"
options={{ label: 'Messages' }}
/>
<Resource
name="audit_logs"
list={AuditLogList}
options={{ label: 'Audit Logs' }}
/>
</>
)}
</>
)}
</Admin>
);
+7
View File
@@ -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
);
@@ -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<string, unknown> | null,
newValues?: Record<string, unknown> | 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 <RecordType extends { id: string } = { id: string }>(
resource: string,
params: CreateParams
) => {
const result = await baseDataProvider.create<RecordType>(resource, params);
if (AUDITED_TABLES.includes(resource)) {
await logAction(resource, result.data.id, 'INSERT', null, result.data as Record<string, unknown>);
}
return result;
},
update: async <RecordType extends { id: string } = { id: string }>(
resource: string,
params: UpdateParams
) => {
// Fetch old values before update
let oldValues: Record<string, unknown> | 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<RecordType>(resource, params);
if (AUDITED_TABLES.includes(resource)) {
await logAction(resource, params.id as string, 'UPDATE', oldValues, result.data as Record<string, unknown>);
}
return result;
},
delete: async <RecordType extends { id: string } = { id: string }>(
resource: string,
params: DeleteParams
) => {
// Fetch old values before delete
let oldValues: Record<string, unknown> | 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<RecordType>(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);
},
};
+108 -2
View File
@@ -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<AdminRole, Array<{ action: string; resource: string }>> = {
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(),
};
+18 -20
View File
@@ -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);
+64
View File
@@ -0,0 +1,64 @@
import {
List,
Datagrid,
TextField,
DateField,
FunctionField,
SelectInput,
TextInput,
DateInput,
} from 'react-admin';
const auditLogFilters = [
<TextInput key="table" label="Table" source="table_name" alwaysOn />,
<SelectInput
key="action"
source="action"
choices={[
{ id: 'INSERT', name: 'Create' },
{ id: 'UPDATE', name: 'Update' },
{ id: 'DELETE', name: 'Delete' },
]}
/>,
<DateInput key="from" source="created_at@gte" label="From Date" />,
<DateInput key="to" source="created_at@lte" label="To Date" />,
];
export const AuditLogList = () => (
<List
filters={auditLogFilters}
sort={{ field: 'created_at', order: 'DESC' }}
>
<Datagrid bulkActionButtons={false}>
<DateField source="created_at" showTime label="Timestamp" />
<TextField source="table_name" label="Table" />
<FunctionField
label="Action"
render={(record: { action: string }) => {
const actionMap: Record<string, string> = {
INSERT: 'Create',
UPDATE: 'Update',
DELETE: 'Delete',
};
return actionMap[record.action] || record.action;
}}
/>
<TextField source="record_id" label="Record ID" />
<TextField source="admin_role" label="Role" />
<FunctionField
label="Changed Fields"
render={(record: { changed_fields?: string[] }) =>
record.changed_fields?.join(', ') || '-'
}
/>
<FunctionField
label="Summary"
render={(record: { action: string; changed_fields?: string[] }) => {
if (record.action === 'INSERT') return 'New record created';
if (record.action === 'DELETE') return 'Record deleted';
return `${record.changed_fields?.length || 0} fields changed`;
}}
/>
</Datagrid>
</List>
);
+21
View File
@@ -0,0 +1,21 @@
import {
List,
Datagrid,
TextField,
DateField,
TextInput,
} from 'react-admin';
const coupleFilters = [
<TextInput key="invite" label="Invite Code" source="invite_code" alwaysOn />,
];
export const CoupleList = () => (
<List filters={coupleFilters}>
<Datagrid bulkActionButtons={false}>
<TextField source="id" label="Couple ID" />
<TextField source="invite_code" label="Invite Code" />
<DateField source="created_at" label="Created" />
</Datagrid>
</List>
);
+59
View File
@@ -0,0 +1,59 @@
import {
Show,
SimpleShowLayout,
TextField,
DateField,
ReferenceField,
ReferenceManyField,
List,
Datagrid,
BooleanField,
Labeled,
} from 'react-admin';
export const MatchShow = () => (
<Show>
<SimpleShowLayout>
<TextField source="id" label="Match ID" />
<ReferenceField source="question_id" reference="questions" label="Question">
<TextField source="text" />
</ReferenceField>
<TextField source="match_type" />
<BooleanField source="is_new" label="Unread" />
<DateField source="created_at" />
<Labeled label="Chat Messages" fullWidth>
<ReferenceManyField
reference="messages"
target="match_id"
sort={{ field: 'created_at', order: 'ASC' }}
>
<Datagrid bulkActionButtons={false}>
<ReferenceField source="user_id" reference="profiles" link={false}>
<TextField source="name" />
</ReferenceField>
<TextField source="content" label="Message" />
<DateField source="created_at" showTime label="Sent" />
<DateField source="read_at" showTime label="Read" emptyText="-" />
</Datagrid>
</ReferenceManyField>
</Labeled>
</SimpleShowLayout>
</Show>
);
export const MatchList = () => (
<List>
<Datagrid rowClick="show" bulkActionButtons={false}>
<ReferenceField source="couple_id" reference="couples" link={false}>
<TextField source="id" />
</ReferenceField>
<ReferenceField source="question_id" reference="questions">
<TextField source="text" />
</ReferenceField>
<TextField source="match_type" />
<BooleanField source="is_new" label="Unread" />
<DateField source="created_at" />
</Datagrid>
</List>
);
+25
View File
@@ -0,0 +1,25 @@
import {
List,
Datagrid,
TextField,
DateField,
ReferenceField,
TextInput,
} from 'react-admin';
const messageFilters = [
<TextInput key="content" label="Search content" source="content@ilike" alwaysOn />,
];
export const MessageList = () => (
<List filters={messageFilters} sort={{ field: 'created_at', order: 'DESC' }}>
<Datagrid bulkActionButtons={false}>
<ReferenceField source="user_id" reference="profiles" label="Sender">
<TextField source="name" />
</ReferenceField>
<TextField source="content" />
<DateField source="created_at" showTime label="Sent At" />
<DateField source="read_at" showTime label="Read At" />
</Datagrid>
</List>
);
+84 -5
View File
@@ -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 = () => (
<List>
<Datagrid>
<Datagrid rowClick="show">
<TextField source="name" />
<TextField source="email" />
<BooleanField source="is_premium" />
<ReferenceField source="couple_id" reference="couples">
<ReferenceField source="couple_id" reference="couples" link={false}>
<TextField source="id" />
</ReferenceField>
<DateField source="created_at" />
@@ -14,9 +31,71 @@ export const ProfileList = () => (
</List>
);
export const ProfileShow = () => (
<Show>
<TabbedShowLayout>
<TabbedShowLayout.Tab label="Profile">
<TextField source="id" />
<TextField source="name" />
<BooleanField source="is_premium" />
<ReferenceField source="couple_id" reference="couples">
<TextField source="invite_code" />
</ReferenceField>
<DateField source="created_at" />
</TabbedShowLayout.Tab>
<TabbedShowLayout.Tab label="Matches & Chats">
<ReferenceManyField
label="Matches"
reference="matches"
target="couple_id"
source="couple_id"
>
<Datagrid rowClick="show" bulkActionButtons={false}>
<ReferenceField source="question_id" reference="questions">
<TextField source="text" />
</ReferenceField>
<TextField source="match_type" />
<BooleanField source="is_new" label="Unread" />
<DateField source="created_at" />
</Datagrid>
</ReferenceManyField>
</TabbedShowLayout.Tab>
<TabbedShowLayout.Tab label="Responses">
<ReferenceManyField
label="Responses"
reference="responses"
target="user_id"
>
<Datagrid bulkActionButtons={false}>
<ReferenceField source="question_id" reference="questions">
<TextField source="text" />
</ReferenceField>
<TextField source="answer" />
<DateField source="created_at" />
</Datagrid>
</ReferenceManyField>
</TabbedShowLayout.Tab>
</TabbedShowLayout>
</Show>
);
export const ProfileEdit = () => (
<Edit>
<SimpleForm>
<TextInput source="name" />
<BooleanInput source="is_premium" label="Premium Status" />
<ReferenceInput source="couple_id" reference="couples">
<SelectInput optionText="id" />
</ReferenceInput>
</SimpleForm>
</Edit>
);
export const ResponseList = () => (
<List>
<Datagrid>
<Datagrid bulkActionButtons={false}>
<ReferenceField source="user_id" reference="profiles">
<TextField source="name" />
</ReferenceField>
+20
View File
@@ -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"]
}
+3
View File
@@ -6,5 +6,8 @@ export default defineConfig({
server: {
host: true,
port: 3001,
watch: {
usePolling: true,
},
},
});
@@ -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;
@@ -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;
@@ -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());
+337
View File
@@ -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
+287
View File
@@ -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" |
+293
View File
@@ -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<void>;
markAsSeen: (matchId: string) => Promise<void>;
markAllAsSeen: () => Promise<void>;
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
+154
View File
@@ -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` |
+182
View File
@@ -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
+277
View File
@@ -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
```