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 { Admin, Resource } from "react-admin";
|
||||||
import { dataProvider } from "./providers/supabaseProvider";
|
import { auditDataProvider } from "./providers/auditDataProvider";
|
||||||
import { PackList, PackEdit, PackCreate } from "./resources/packs";
|
import { PackList, PackEdit, PackCreate } from "./resources/packs";
|
||||||
import { QuestionList, QuestionEdit, QuestionCreate } from "./resources/questions";
|
import { QuestionList, QuestionEdit, QuestionCreate } from "./resources/questions";
|
||||||
import { ProfileList, ResponseList } from "./resources/users";
|
import { ProfileList, ProfileShow, ProfileEdit, ResponseList } from "./resources/users";
|
||||||
import { authProvider } from "./providers/authProvider";
|
import { CoupleList } from "./resources/couples";
|
||||||
|
import { MatchList, MatchShow } from "./resources/matches";
|
||||||
|
import { AuditLogList } from "./resources/auditLogs";
|
||||||
|
import { authProvider, AdminPermissions } from "./providers/authProvider";
|
||||||
|
|
||||||
const App = () => (
|
const App = () => (
|
||||||
<Admin dataProvider={dataProvider} authProvider={authProvider} requireAuth>
|
<Admin
|
||||||
<Resource
|
dataProvider={auditDataProvider}
|
||||||
name="question_packs"
|
authProvider={authProvider}
|
||||||
list={PackList}
|
requireAuth
|
||||||
edit={PackEdit}
|
>
|
||||||
create={PackCreate}
|
{(permissions: AdminPermissions) => (
|
||||||
recordRepresentation="name"
|
<>
|
||||||
/>
|
{/* Question Packs - accessible to all admins */}
|
||||||
<Resource
|
<Resource
|
||||||
name="questions"
|
name="question_packs"
|
||||||
list={QuestionList}
|
list={PackList}
|
||||||
edit={QuestionEdit}
|
edit={PackEdit}
|
||||||
create={QuestionCreate}
|
create={PackCreate}
|
||||||
/>
|
recordRepresentation="name"
|
||||||
<Resource
|
options={{ label: 'Question Packs' }}
|
||||||
name="profiles"
|
/>
|
||||||
list={ProfileList}
|
|
||||||
/>
|
{/* Questions - accessible to all admins */}
|
||||||
<Resource
|
<Resource
|
||||||
name="responses"
|
name="questions"
|
||||||
list={ResponseList}
|
list={QuestionList}
|
||||||
/>
|
edit={QuestionEdit}
|
||||||
<Resource name="couples" />
|
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>
|
</Admin>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,11 @@
|
|||||||
|
import { createClient } from '@supabase/supabase-js';
|
||||||
|
|
||||||
export const supabaseConfig = {
|
export const supabaseConfig = {
|
||||||
url: import.meta.env.VITE_SUPABASE_URL,
|
url: import.meta.env.VITE_SUPABASE_URL,
|
||||||
anonKey: import.meta.env.VITE_SUPABASE_ANON_KEY,
|
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 { AuthProvider } from 'react-admin';
|
||||||
import { supabase } from '../config';
|
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 = {
|
export const authProvider: AuthProvider = {
|
||||||
login: async ({ username, password }) => {
|
login: async ({ username, password }) => {
|
||||||
const { data, error } = await supabase.auth.signInWithPassword({
|
const { data, error } = await supabase.auth.signInWithPassword({
|
||||||
@@ -10,21 +41,96 @@ export const authProvider: AuthProvider = {
|
|||||||
if (error) {
|
if (error) {
|
||||||
throw new Error(error.message);
|
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();
|
return Promise.resolve();
|
||||||
},
|
},
|
||||||
|
|
||||||
logout: async () => {
|
logout: async () => {
|
||||||
|
localStorage.removeItem(PERMISSIONS_KEY);
|
||||||
await supabase.auth.signOut();
|
await supabase.auth.signOut();
|
||||||
return Promise.resolve();
|
return Promise.resolve();
|
||||||
},
|
},
|
||||||
|
|
||||||
checkError: async (error) => {
|
checkError: async (error) => {
|
||||||
if (error.status === 401 || error.status === 403) {
|
if (error.status === 401 || error.status === 403) {
|
||||||
|
localStorage.removeItem(PERMISSIONS_KEY);
|
||||||
return Promise.reject();
|
return Promise.reject();
|
||||||
}
|
}
|
||||||
return Promise.resolve();
|
return Promise.resolve();
|
||||||
},
|
},
|
||||||
|
|
||||||
checkAuth: async () => {
|
checkAuth: async () => {
|
||||||
const session = await supabase.auth.getSession();
|
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 { supabaseDataProvider } from 'ra-supabase';
|
||||||
|
import { supabase } from '../config';
|
||||||
|
|
||||||
export const dataProvider = supabaseDataProvider(
|
export { supabase };
|
||||||
supabase,
|
|
||||||
{
|
const resources = {
|
||||||
resources: {
|
question_packs: ['id', 'name', 'description', 'icon', 'is_premium', 'is_public', 'sort_order', 'created_at'],
|
||||||
question_packs: 'question_packs',
|
questions: ['id', 'pack_id', 'text', 'partner_text', 'intensity', 'created_at'],
|
||||||
questions: 'questions',
|
profiles: ['id', 'name', 'avatar_url', 'is_premium', 'couple_id', 'created_at'],
|
||||||
profiles: 'profiles',
|
couples: ['id', 'invite_code', 'created_at'],
|
||||||
couples: 'couples'
|
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 = () => (
|
export const ProfileList = () => (
|
||||||
<List>
|
<List>
|
||||||
<Datagrid>
|
<Datagrid rowClick="show">
|
||||||
<TextField source="name" />
|
<TextField source="name" />
|
||||||
<TextField source="email" />
|
|
||||||
<BooleanField source="is_premium" />
|
<BooleanField source="is_premium" />
|
||||||
<ReferenceField source="couple_id" reference="couples">
|
<ReferenceField source="couple_id" reference="couples" link={false}>
|
||||||
<TextField source="id" />
|
<TextField source="id" />
|
||||||
</ReferenceField>
|
</ReferenceField>
|
||||||
<DateField source="created_at" />
|
<DateField source="created_at" />
|
||||||
@@ -14,9 +31,71 @@ export const ProfileList = () => (
|
|||||||
</List>
|
</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 = () => (
|
export const ResponseList = () => (
|
||||||
<List>
|
<List>
|
||||||
<Datagrid>
|
<Datagrid bulkActionButtons={false}>
|
||||||
<ReferenceField source="user_id" reference="profiles">
|
<ReferenceField source="user_id" reference="profiles">
|
||||||
<TextField source="name" />
|
<TextField source="name" />
|
||||||
</ReferenceField>
|
</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: {
|
server: {
|
||||||
host: true,
|
host: true,
|
||||||
port: 3001,
|
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