mirror of
https://github.com/jjbrunton/Sauci.git
synced 2026-10-31 10:19:43 +00:00
Update
This commit is contained in:
1 parent
358c2be0ee
commit
b10eae1bf3
26 files changed
+2509
-53
No files matched your search
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Bash(wc:*)"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
node_modules
|
||||
.git
|
||||
.gitignore
|
||||
*.md
|
||||
.env*
|
||||
.DS_Store
|
||||
dist
|
||||
.expo
|
||||
android
|
||||
ios
|
||||
*.log
|
||||
.turbo
|
||||
coverage
|
||||
.nyc_output
|
||||
@@ -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
|
||||
@@ -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"]
|
||||
@@ -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
|
||||
+72
-26
@@ -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>
|
||||
<Resource
|
||||
name="question_packs"
|
||||
list={PackList}
|
||||
edit={PackEdit}
|
||||
create={PackCreate}
|
||||
recordRepresentation="name"
|
||||
/>
|
||||
<Resource
|
||||
name="questions"
|
||||
list={QuestionList}
|
||||
edit={QuestionEdit}
|
||||
create={QuestionCreate}
|
||||
/>
|
||||
<Resource
|
||||
name="profiles"
|
||||
list={ProfileList}
|
||||
/>
|
||||
<Resource
|
||||
name="responses"
|
||||
list={ResponseList}
|
||||
/>
|
||||
<Resource name="couples" />
|
||||
<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="messages"
|
||||
options={{ label: 'Messages' }}
|
||||
/>
|
||||
<Resource
|
||||
name="audit_logs"
|
||||
list={AuditLogList}
|
||||
options={{ label: 'Audit Logs' }}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Admin>
|
||||
);
|
||||
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
@@ -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(),
|
||||
};
|
||||
@@ -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);
|
||||
@@ -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>
|
||||
);
|
||||
@@ -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>
|
||||
);
|
||||
@@ -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>
|
||||
);
|
||||
@@ -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>
|
||||
);
|
||||
@@ -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>
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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());
|
||||
@@ -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
|
||||
@@ -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" |
|
||||
@@ -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
|
||||
@@ -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
@@ -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
|
||||
@@ -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
|
||||
```
|
||||
Reference in new issue
Block a user