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

No files matched your search

+337
View File
@@ -0,0 +1,337 @@
# Authentication System
This document explains how authentication works in Sauci, including supported methods, session management, and deep linking.
## Overview
Sauci uses Supabase Auth with three authentication methods:
1. **Magic Link** (passwordless email)
2. **Email/Password** with verification
3. **Apple Sign-In** (iOS native + OAuth fallback)
## Authentication Methods
### Magic Link
Passwordless authentication via email OTP.
```typescript
const { error } = await supabase.auth.signInWithOtp({
email: email.trim(),
options: {
emailRedirectTo: Linking.createURL("/(auth)/login"),
},
});
```
**Flow:**
1. User enters email
2. Supabase sends email with magic link
3. User taps link → Opens app via deep link
4. App extracts `token_hash` and verifies OTP
5. Session established
### Email/Password
Traditional email/password with email verification.
**Sign Up:**
```typescript
const { data, error } = await supabase.auth.signUp({
email: email.trim(),
password,
});
```
**Sign In:**
```typescript
const { error } = await supabase.auth.signInWithPassword({
email: email.trim(),
password,
});
```
**Verification Flow:**
1. User signs up → Receives verification email
2. Shows "Verify your email" screen with:
- "I've verified my email" button (attempts sign-in)
- "Resend verification email" button (60s cooldown)
3. User clicks email link → Email verified
4. User taps "I've verified" → Sign-in succeeds
**Resend Logic:**
```typescript
const { error } = await supabase.auth.resend({
type: 'signup',
email: pendingVerification.email,
});
// 60 second cooldown between resends
```
### Apple Sign-In
Native Apple authentication on iOS with OAuth fallback.
**Native (iOS with expo-apple-authentication):**
```typescript
const credential = await AppleAuthentication.signInAsync({
requestedScopes: [
AppleAuthenticationScope.FULL_NAME,
AppleAuthenticationScope.EMAIL,
],
});
const { error } = await supabase.auth.signInWithIdToken({
provider: "apple",
token: credential.identityToken,
});
```
**OAuth Fallback (web, Expo Go):**
```typescript
const { error } = await supabase.auth.signInWithOAuth({
provider: "apple",
options: {
redirectTo: Linking.createURL("/(auth)/login"),
},
});
```
## Session Management
### Storage Adapter
Sessions are stored in AsyncStorage (not SecureStore due to JWT size limits):
```typescript
const ExpoStorageAdapter = {
getItem: async (key) => {
if (Platform.OS === "web") {
return window.localStorage.getItem(key);
}
return AsyncStorage.getItem(key);
},
setItem: async (key, value) => { ... },
removeItem: async (key) => { ... },
};
export const supabase = createClient(url, key, {
auth: {
storage: ExpoStorageAdapter,
autoRefreshToken: true,
persistSession: true,
detectSessionInUrl: Platform.OS === "web",
},
});
```
### Auth State Listener
The root layout listens for auth changes:
```typescript
supabase.auth.onAuthStateChange(async (event, session) => {
if (event === "SIGNED_IN" || event === "TOKEN_REFRESHED") {
fetchUser();
setUserContext(session.user.id, session.user.email); // Sentry
} else if (event === "SIGNED_OUT") {
await clearPushToken(userId); // Remove push token
setUser(null);
clearUserContext();
queryClient.clear();
}
});
```
### Sign Out
Sign out clears all app state:
```typescript
signOut: async () => {
// Clear local state FIRST (ensures UI updates)
set({ user: null, couple: null, partner: null, ... });
// Clear other stores
useMatchStore.getState().clearMatches();
usePacksStore.getState().clearPacks();
useMessageStore.getState().clearMessages();
useSubscriptionStore.getState().clearSubscription();
// Sign out from Supabase (don't block on this)
await supabase.auth.signOut();
},
```
## Deep Linking
### URL Schemes
| Platform | Scheme | Example |
|----------|--------|---------|
| iOS/Android | `sauci://` | `sauci://auth/callback` |
| Expo Dev | `exp://` | `exp://localhost:8081` |
### Deep Link Handler
The root layout processes auth deep links:
```typescript
const handleDeepLink = async (url: string) => {
const parsedUrl = new URL(url);
const params = new URLSearchParams(parsedUrl.search);
const hashParams = new URLSearchParams(parsedUrl.hash.replace('#', ''));
const accessToken = params.get('access_token') || hashParams.get('access_token');
const refreshToken = params.get('refresh_token') || hashParams.get('refresh_token');
const tokenHash = params.get('token_hash') || hashParams.get('token_hash');
const type = params.get('type') || hashParams.get('type');
if (accessToken && refreshToken) {
// OAuth flow - set session directly
await supabase.auth.setSession({ access_token, refresh_token });
} else if (tokenHash && type) {
// Magic link / email verification
await supabase.auth.verifyOtp({ token_hash, type });
}
};
```
### Deep Link Sources
1. **Initial URL** - App opened via link (cold start)
2. **URL Event** - Link received while app running
```typescript
// Cold start
Linking.getInitialURL().then(handleDeepLink);
// App running
Linking.addEventListener("url", (event) => handleDeepLink(event.url));
```
## Profile Auto-Creation
Database trigger creates profile on signup:
```sql
CREATE FUNCTION public.handle_new_user()
RETURNS TRIGGER AS $$
BEGIN
INSERT INTO public.profiles (id, name, avatar_url)
VALUES (
NEW.id,
COALESCE(NEW.raw_user_meta_data->>'name',
NEW.raw_user_meta_data->>'full_name'),
NEW.raw_user_meta_data->>'avatar_url'
);
RETURN NEW;
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;
CREATE TRIGGER on_auth_user_created
AFTER INSERT ON auth.users
FOR EACH ROW EXECUTE FUNCTION public.handle_new_user();
```
## Push Token Registration
After authentication, push token is registered:
```typescript
// In app layout after fetchUser
const token = await registerForPushNotificationsAsync();
if (token && user.id) {
await savePushToken(user.id, token);
}
```
Tokens are stored in `profiles.push_token` and cleared on sign out.
## Auth Flow Diagram
```
┌─────────────────────────────────────────────────────────────┐
│ Login Screen │
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
│ │ Magic Link │ │ Password │ │ Apple │ │
│ └──────────────┘ └──────────────┘ └──────────────┘ │
└─────────────────────────────────────────────────────────────┘
│ │ │
▼ ▼ ▼
┌────────────────┐ ┌────────────────┐ ┌────────────────┐
│ signInWithOtp │ │ signUp/signIn │ │ signInWithId │
│ │ │ WithPassword │ │ Token (native) │
└────────────────┘ └────────────────┘ └────────────────┘
│ │ │
▼ ▼ ▼
┌────────────────┐ ┌────────────────┐ ┌────────────────┐
│ Email sent │ │ Verify email │ │ ID Token │
│ (magic link) │ │ (if signup) │ │ validated │
└────────────────┘ └────────────────┘ └────────────────┘
│ │ │
└──────────┬─────────┴────────────────────┘
▼
┌────────────────────┐
│ onAuthStateChange │
│ event: SIGNED_IN │
└──────────┬─────────┘
▼
┌────────────────────┐
│ fetchUser() │
│ Load profile from │
│ database │
└──────────┬─────────┘
▼
┌────────────────────┐
│ Register push │
│ token if granted │
└──────────┬─────────┘
▼
┌────────────────────┐
│ Navigate to app │
│ (or pairing if │
│ no partner) │
└────────────────────┘
```
## Supabase Auth Config
From `apps/supabase/config.toml`:
```toml
[auth]
enabled = true
site_url = "exp://localhost:8081"
additional_redirect_urls = ["sauci://auth/callback"]
jwt_expiry = 3600 # 1 hour
enable_refresh_token_rotation = true
refresh_token_reuse_interval = 10
enable_signup = true
[auth.external.apple]
enabled = true
client_id = "env(APPLE_CLIENT_ID)"
secret = "env(APPLE_CLIENT_SECRET)"
[auth.email]
enable_signup = true
double_confirm_changes = true
enable_confirmations = false # Email verification disabled by default
```
## Error Handling
| Error | Cause | Handling |
|-------|-------|----------|
| "Email not confirmed" | Sign-in before verification | Show verification pending screen |
| "Invalid login credentials" | Wrong password | Show error message |
| Empty identities array | Email already exists (signup) | Prompt to sign in instead |
| "ERR_REQUEST_CANCELED" | User canceled Apple sign-in | Silent (no error shown) |
## Security Considerations
- JWTs stored in AsyncStorage (encrypted on iOS, less secure on Android)
- Push tokens cleared on sign out
- Session auto-refresh enabled
- OAuth state verified via deep link parameters
+287
View File
@@ -0,0 +1,287 @@
# Couple Pairing System
This document explains how couples are created, joined, and managed in Sauci.
## Overview
Couples are the core relationship unit in Sauci. Two users link via an invite code to form a couple, enabling shared question answering and match detection.
## States
A user can be in one of three states:
| State | `couple_id` | `partner` | Description |
|-------|-------------|-----------|-------------|
| **Unpaired** | `null` | `null` | Not in any couple |
| **Waiting** | Set | `null` | Created couple, waiting for partner |
| **Paired** | Set | Exists | Both partners joined |
## Edge Functions
### `manage-couple` (POST)
Handles creating and joining couples.
**Create Couple** (empty body):
```javascript
// Request
{ }
// Response
{ success: true, couple_id: "uuid", invite_code: "abc12345" }
```
**Join Couple** (with invite code):
```javascript
// Request
{ invite_code: "abc12345" }
// Response
{ success: true, couple_id: "uuid" }
```
**Validations:**
- User cannot already be in a couple
- Invite code must exist
- Target couple must have < 2 members
### `manage-couple` (DELETE)
Leaves current couple (sets `couple_id` to null).
```javascript
// Response
{ success: true }
```
### `delete-relationship` (DELETE)
**Destructive** - Completely deletes the couple and all associated data.
**What Gets Deleted:**
1. Chat media from storage (iterates through all match folders)
2. Both partners' `couple_id` set to `null`
3. Couple record deleted (cascades to):
- All responses
- All matches
- All messages
- All couple_packs settings
## Database Schema
### Couple Record
```sql
CREATE TABLE public.couples (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
invite_code TEXT UNIQUE DEFAULT substr(md5(random()::text), 1, 8),
created_at TIMESTAMPTZ DEFAULT NOW()
);
```
**Invite Code:**
- 8 character hex string (e.g., `a1b2c3d4`)
- Auto-generated on insert
- Case-insensitive matching (lowercased on lookup)
### Profile Link
```sql
-- profiles table
couple_id UUID REFERENCES couples(id) ON DELETE SET NULL
```
When a couple is deleted, both profiles automatically have `couple_id` set to `null` (preserving accounts).
### Size Enforcement
Database trigger prevents > 2 members per couple:
```sql
CREATE TRIGGER enforce_couple_size
BEFORE INSERT OR UPDATE OF couple_id ON profiles
FOR EACH ROW EXECUTE FUNCTION check_couple_size();
-- Raises exception if count >= 2
```
This prevents race conditions where multiple users try to join simultaneously.
## Client Flow
### Pairing Screen (`app/(app)/pairing.tsx`)
**Unpaired State:**
```
┌─────────────────────────────┐
│ 🔗 Pair Up │
│ │
│ ┌───────────────────────┐ │
│ │ Have a code? │ │
│ │ [Enter invite code] │ │
│ │ [Join Partner] │ │
│ └───────────────────────┘ │
│ │
│ ── or ── │
│ │
│ [Create New Code] │
└─────────────────────────────┘
```
**Waiting State:**
```
┌─────────────────────────────┐
│ ❤️ Partner Code │
│ │
│ Share this code with │
│ your partner │
│ │
│ ┌───────────────────┐ │
│ │ ABC12345 📋 │ │
│ └───────────────────┘ │
│ │
│ [Share Code] │
│ │
│ Waiting for partner... │
│ ⏳ │
└─────────────────────────────┘
```
### Real-Time Partner Detection
When waiting for partner, the screen listens for updates:
```typescript
// Polling fallback every 5 seconds
const pollInterval = setInterval(() => {
fetchCouple();
}, 5000);
// Real-time subscription
const subscription = supabase
.channel(`couple-${couple.id}`)
.on("postgres_changes", {
event: "UPDATE",
schema: "public",
table: "profiles",
filter: `couple_id=eq.${couple.id}`,
}, async (payload) => {
if (payload.new.id !== user?.id) {
await fetchCouple();
}
})
.subscribe();
```
### Auto-Redirect
When partner is detected, user is redirected:
```typescript
useEffect(() => {
if (couple && partner) {
router.replace("/(app)/");
}
}, [couple, partner]);
```
## Flow Diagrams
### Create & Share Flow
```
User A: Unpaired
│
▼
[Create New Code]
│
▼
POST manage-couple (empty body)
│
├──▶ Create couple record
│ (generates invite_code)
│
├──▶ Set user A's couple_id
│
▼
User A: Waiting
│
├──▶ Display invite code
│
├──▶ [Share Code] → Native share sheet
│
└──▶ Subscribe to realtime + poll
```
### Join Flow
```
User B: Unpaired
│
▼
[Enter Code] → "abc12345"
│
▼
POST manage-couple { invite_code: "abc12345" }
│
├──▶ Validate code exists
│
├──▶ Check couple has < 2 members
│
├──▶ Set user B's couple_id
│
▼
User B: Paired
│
├──▶ Realtime event fires
│
▼
User A: Receives update → Paired
```
### Delete Relationship Flow
```
User: Profile → Settings → Delete Relationship
│
▼
Confirmation Dialog
│
▼
DELETE delete-relationship
│
├──▶ List all matches for couple
│
├──▶ Delete chat-media storage files
│
├──▶ Set both profiles.couple_id = null
│
├──▶ DELETE couple record
│ (cascades to responses, matches, messages, couple_packs)
│
▼
Both users: Unpaired
```
## Cascade Behavior
When a couple is deleted, foreign key cascades clean up:
| Table | Action |
|-------|--------|
| `profiles` | `couple_id` set to `null` |
| `responses` | Rows deleted |
| `matches` | Rows deleted |
| `messages` | Rows deleted |
| `couple_packs` | Rows deleted |
Storage (chat-media) is manually cleaned before the cascade.
## Error Handling
| Error | Cause | Message |
|-------|-------|---------|
| Already paired | User tries to create/join while in couple | "You are already in a couple" |
| Invalid code | Invite code doesn't exist | "Invalid invite code" |
| Couple full | Couple already has 2 members | "This couple already has two partners" |
| DB trigger | Race condition caught | "A couple can only have 2 members" |
+293
View File
@@ -0,0 +1,293 @@
# Match & Notification System
This document explains how matches are detected, stored, and how push notifications are triggered.
## Overview
A **match** occurs when both partners in a couple answer a question positively (not "no"). Matches unlock a chat thread for discussing that topic.
## Match Types
| Type | Partner A | Partner B | Description |
|------|-----------|-----------|-------------|
| `yes_yes` | Yes | Yes | Both enthusiastic |
| `yes_maybe` | Yes | Maybe | One enthusiastic, one open |
| `maybe_maybe` | Maybe | Maybe | Both open to exploring |
**No Match Cases:**
- Either partner answers "No" → No match created
- Only one partner has answered → No match yet (waiting)
## Match Detection Flow
```
┌─────────────────────────────────────────────────────────────┐
│ submit-response Edge Function │
└─────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────┐
│ 1. Validate user JWT │
│ 2. Get user's couple_id from profiles │
│ 3. UPSERT response (user_id, question_id, answer) │
└─────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────┐
│ 4. Query partner's response for same question │
│ SELECT answer FROM responses │
│ WHERE couple_id = ? AND question_id = ? AND user_id != ? │
└─────────────────────────────────────────────────────────────┘
│
┌───────────────┴───────────────┐
▼ ▼
Partner answered? No partner response
│ │
▼ ▼
calculateMatch(a1, a2) Return { response }
│ (no match yet)
▼
┌─────────────────────────────────────────────────────────────┐
│ function calculateMatch(a1, a2): │
│ if (a1 === "no" || a2 === "no") return null │
│ if (a1 === "yes" && a2 === "yes") return "yes_yes" │
│ if (a1 === "maybe" && a2 === "maybe") return "maybe_maybe"│
│ return "yes_maybe" │
└─────────────────────────────────────────────────────────────┘
│
┌───────────────┴───────────────┐
▼ ▼
Match type found null (no match)
│ │
▼ ▼
┌──────────────────────────┐ Return { response }
│ UPSERT match record │
│ (couple_id, question_id) │
└────────────┬─────────────┘
│
▼
┌──────────────────────────┐
│ Invoke send-notification │
│ { couple_id, match_id } │
└────────────┬─────────────┘
│
▼
Return { response, match }
```
## Match Storage
```sql
CREATE TABLE public.matches (
id UUID PRIMARY KEY,
couple_id UUID NOT NULL REFERENCES couples(id),
question_id UUID NOT NULL REFERENCES questions(id),
match_type match_type NOT NULL, -- yes_yes, yes_maybe, maybe_maybe
is_new BOOLEAN DEFAULT TRUE, -- For notification badge
created_at TIMESTAMPTZ DEFAULT NOW(),
UNIQUE(couple_id, question_id) -- One match per question per couple
);
```
**UPSERT Behavior:**
Matches use `onConflict: "couple_id,question_id"` so if a user changes their answer, the match type updates rather than creating duplicates.
## Push Notifications
### Match Notification (`send-notification`)
Triggered when a new match is created.
**Input:**
```json
{ "couple_id": "uuid", "match_id": "uuid" }
```
**Behavior:**
1. Fetch all profiles in couple with push tokens
2. Build notification for both partners:
```javascript
{
to: profile.push_token,
title: "It's a match! 💕",
body: "You and your partner matched on something new!",
sound: "default",
data: { match_id, type: "match" }
}
```
3. Send via Expo Push API
**Privacy:** Notification content is generic - doesn't reveal the question topic.
### Message Notification (`send-message-notification`)
Triggered when a chat message is sent.
**Input:**
```json
{ "match_id": "uuid", "sender_id": "uuid" }
```
**Behavior:**
1. Find the match's couple_id
2. Get recipient's push token (partner, not sender)
3. Build notification:
```javascript
{
to: recipient.push_token,
title: "New message",
body: "Your partner sent you a message",
sound: "default",
data: { type: "message", match_id, message_id }
}
```
4. Send via Expo Push API
**Privacy:** Message content not included in notification.
## Client State Management
### Match Store (`src/store/index.ts`)
```typescript
interface MatchState {
matches: Match[];
newMatchesCount: number;
fetchMatches: () => Promise<void>;
markAsSeen: (matchId: string) => Promise<void>;
markAllAsSeen: () => Promise<void>;
addMatch: (match: Match) => void;
clearMatches: () => void;
}
```
### Fetching Matches
The `fetchMatches` function:
1. Queries matches with joined question data
2. Fetches all responses to determine who answered first (for two-part display)
3. Counts unread messages per match
4. Calculates new matches count (where `is_new = true`)
```typescript
const { data: matches } = await supabase
.from("matches")
.select(`*, question:questions(*)`)
.order("created_at", { ascending: false });
```
### Match Display
The matches screen shows:
- Question text (user's version for two-part questions)
- Partner's question text (if two-part)
- Match type badge (`YES + YES` or `YES + MAYBE`)
- Unread message indicator
- Date
**Two-Part Question Display Logic:**
```typescript
// Determine which text each person saw based on response timestamps
if (userResponse.created_at > partnerResponse.created_at) {
userText = question.partner_text; // User was second
partnerText = question.text; // Partner was first
}
```
## Chat System
Each match unlocks a chat thread stored in `messages` table:
```sql
CREATE TABLE public.messages (
id UUID PRIMARY KEY,
match_id UUID NOT NULL REFERENCES matches(id),
user_id UUID NOT NULL REFERENCES auth.users(id),
content TEXT,
media_path TEXT, -- Storage path for images
read_at TIMESTAMPTZ,
created_at TIMESTAMPTZ,
CONSTRAINT content_or_media CHECK (content IS NOT NULL OR media_path IS NOT NULL)
);
```
### Real-Time Chat
Messages use Supabase Realtime:
```sql
ALTER PUBLICATION supabase_realtime ADD TABLE messages;
```
### Message Read Tracking
- `read_at` is `NULL` for unread messages
- Updated when recipient opens chat thread
- Used to calculate unread counts and badges
## Notification Flow Diagram
```
User A swipes "Yes"
│
▼
submit-response
│
├──▶ Save response
│
├──▶ Check partner response
│ │
│ ▼
│ Partner said "Yes"?
│ │
│ ▼
│ Create match (yes_yes)
│ │
└─────────┼──▶ Invoke send-notification
│
▼
┌─────────────────────┐
│ Expo Push Service │
└─────────────────────┘
│
┌─────────┴─────────┐
▼ ▼
User A Phone User B Phone
"It's a match!" "It's a match!"
```
## RLS Policies
Users can only see matches for their couple:
```sql
CREATE POLICY "Users can view their couple matches"
ON public.matches FOR SELECT
USING (
couple_id IN (
SELECT couple_id FROM profiles WHERE id = auth.uid()
)
);
```
## Badge Management
### New Match Badge
- Set to `is_new = TRUE` on match creation
- Updated to `FALSE` when user views matches screen
- Count displayed on tab bar badge
```typescript
// On matches screen focus
useEffect(() => {
if (matches.length > 0) {
markAllAsSeen(); // Clears is_new flags
}
}, [matches.length]);
```
### Unread Message Badge
- Counted per match from messages where `read_at IS NULL`
- Displayed as bubble on match card
- Cleared when entering chat thread
+154
View File
@@ -0,0 +1,154 @@
# Question Selection Algorithm
This document explains how questions are selected and ordered for users in the swipe interface.
## Overview
Question selection happens in two stages:
1. **Database Layer** - `get_recommended_questions()` PostgreSQL function filters available questions
2. **Client Layer** - `swipe.tsx` applies additional filtering and sorting with bias
## Stage 1: Database Filtering
The `get_recommended_questions(target_pack_id)` function (`apps/supabase/migrations/20251227172500_role_specific_questions.sql`) returns questions the user hasn't answered yet.
### Pack Selection Logic
```
IF target_pack_id provided:
→ Use only that specific pack
ELSE IF couple has any couple_packs entries:
→ Use only packs where enabled = true
ELSE (new couple, no preferences set):
→ Use all public packs (is_public = true)
```
### Question Filtering
For each question in active packs:
- **Excluded**: Questions the current user has already answered
- **Included**: All unanswered questions from active packs
### Returned Fields
| Field | Description |
|-------|-------------|
| `id` | Question UUID |
| `text` | Display text (see two-part logic below) |
| `partner_text` | Original partner text (for reference) |
| `is_two_part` | `true` if question has different text for second responder |
| `pack_id` | Parent pack UUID |
| `intensity` | 1-5 rating |
| `partner_answered` | `true` if partner has already responded |
### Two-Part Questions
Some questions have role-specific text (`partner_text` column). The function handles this:
```
IF partner_text EXISTS AND partner has answered:
→ Return partner_text as display text
ELSE:
→ Return original text
```
This enables questions like:
- First person sees: "Would you like your partner to wake you up with oral?"
- Second person sees: "Would you wake your partner up with oral?"
## Stage 2: Client Sorting & Filtering
Located in `apps/mobile/app/(app)/swipe.tsx`, lines 57-95.
### Skip Filtering
Before sorting, questions are filtered against locally-skipped questions:
```typescript
const filtered = data.filter(q => !skippedIds.has(q.id));
```
**Skip behavior** (`src/lib/skippedQuestions.ts`):
- Swipe down = skip question
- Skipped questions hidden for **24 hours**
- Stored in SecureStore (native) or localStorage (web)
- No server-side record - purely client-side convenience
### Sorting Algorithm with Bias
Questions are sorted by a computed score:
```typescript
const sorted = filtered.sort((a, b) => {
let scoreA = Math.random(); // Base: 0.0 - 1.0
let scoreB = Math.random();
// Apply bias bonuses
if (a.partner_answered && a.is_two_part) scoreA += 1.5;
else if (a.partner_answered) scoreA += 0.7;
else if (a.is_two_part) scoreA += 0.4;
// Same for scoreB...
return scoreB - scoreA; // Descending order
});
```
### Priority Tiers
| Priority | Condition | Score Range | Rationale |
|----------|-----------|-------------|-----------|
| **1st** | `partner_answered` + `is_two_part` | 1.5 - 2.5 | Completes two-part question, immediate match potential |
| **2nd** | `partner_answered` only | 0.7 - 1.7 | High match potential, partner waiting |
| **3rd** | `is_two_part` only | 0.4 - 1.4 | Initiates two-part sequence |
| **4th** | Neither | 0.0 - 1.0 | Standard questions, fully randomized |
### Why This Bias?
1. **Engagement**: Questions partner already answered can immediately create matches, providing instant gratification
2. **Two-part completion**: When partner answered a two-part question first, completing it quickly maintains context and creates better matches
3. **Two-part initiation**: Slightly prioritizes two-part questions to create more engaging interactions
4. **Randomization within tiers**: The random base prevents predictable ordering while maintaining priority bands
## Visual Flow
```
┌─────────────────────────────────────────────────────────────┐
│ get_recommended_questions() │
│ ┌─────────────┐ ┌──────────────┐ ┌───────────────┐ │
│ │ Pack Filter │ → │ User Answered │ → │ Two-Part Text │ │
│ │ │ │ Exclusion │ │ Resolution │ │
│ └─────────────┘ └──────────────┘ └───────────────┘ │
└─────────────────────────────────────────────────────────────┘
↓
┌─────────────────────────────────────────────────────────────┐
│ Client (swipe.tsx) │
│ ┌─────────────┐ ┌──────────────┐ ┌───────────────┐ │
│ │ Skip Filter │ → │ Bias Scoring │ → │ Sort │ │
│ │ (24 hour) │ │ │ │ (Descending) │ │
│ └─────────────┘ └──────────────┘ └───────────────┘ │
└─────────────────────────────────────────────────────────────┘
↓
Questions shown to user
```
## Edge Cases
| Scenario | Behavior |
|----------|----------|
| No packs enabled | Shows "No packs enabled" prompt |
| All questions answered | Shows "All caught up!" with refresh button |
| No partner paired | Shows pairing prompt |
| Pack disabled mid-session | Client filters out questions from disabled packs |
## Configuration
| Setting | Value | Location |
|---------|-------|----------|
| Skip duration | 24 hours | `src/lib/skippedQuestions.ts:6` |
| Two-part + answered bias | +1.5 | `swipe.tsx:78` |
| Answered-only bias | +0.7 | `swipe.tsx:79` |
| Two-part-only bias | +0.4 | `swipe.tsx:80` |
+182
View File
@@ -0,0 +1,182 @@
# Database Schema
## Entity Relationship Diagram
```mermaid
erDiagram
%% Core Authentication
auth_users {
uuid id PK
string email
jsonb raw_user_meta_data
}
%% User Management
profiles {
uuid id PK,FK "references auth.users"
text name
text avatar_url
text push_token
boolean is_premium
uuid couple_id FK
timestamptz created_at
timestamptz updated_at
}
couples {
uuid id PK
text invite_code UK
timestamptz created_at
}
%% Content
question_packs {
uuid id PK
text name
text description
text icon
boolean is_premium
boolean is_public
int sort_order
timestamptz created_at
}
questions {
uuid id PK
uuid pack_id FK
text text
text partner_text "for two-part questions"
int intensity "1-5"
timestamptz created_at
}
%% Couple Pack Selection
couple_packs {
uuid couple_id PK,FK
uuid pack_id PK,FK
boolean enabled
timestamptz created_at
}
%% User Interaction
responses {
uuid id PK
uuid user_id FK
uuid question_id FK
uuid couple_id FK
answer_type answer "yes/no/maybe"
timestamptz created_at
}
matches {
uuid id PK
uuid couple_id FK
uuid question_id FK
match_type match_type "yes_yes/yes_maybe/maybe_maybe"
boolean is_new
timestamptz created_at
}
messages {
uuid id PK
uuid match_id FK
uuid user_id FK
text content
text media_path
timestamptz read_at
timestamptz created_at
}
%% Subscriptions
subscriptions {
uuid id PK
uuid user_id FK
text revenuecat_app_user_id
text product_id
subscription_status status
text[] entitlement_ids
timestamptz purchased_at
timestamptz expires_at
text original_transaction_id
text store
boolean is_sandbox
text cancel_reason
timestamptz grace_period_expires_at
timestamptz created_at
timestamptz updated_at
}
revenuecat_webhook_events {
uuid id PK
text event_id UK
text event_type
text app_user_id
timestamptz processed_at
jsonb payload
}
%% Feedback
feedback {
uuid id PK
uuid user_id FK
feedback_type type "bug/feature_request/general"
text title
text description
text screenshot_url
jsonb device_info
feedback_status status
text admin_notes
timestamptz created_at
timestamptz updated_at
}
%% Relationships
auth_users ||--|| profiles : "extends"
couples ||--o{ profiles : "has members"
couples ||--o{ couple_packs : "selects packs"
couples ||--o{ responses : "has responses"
couples ||--o{ matches : "has matches"
question_packs ||--o{ questions : "contains"
question_packs ||--o{ couple_packs : "selected by"
questions ||--o{ responses : "answered by"
questions ||--o{ matches : "generates"
profiles ||--o{ responses : "submits"
profiles ||--o{ feedback : "submits"
profiles ||--o{ subscriptions : "has"
matches ||--o{ messages : "has chat"
auth_users ||--o{ messages : "sends"
```
## Enums
| Enum | Values |
|------|--------|
| `answer_type` | `yes`, `no`, `maybe` |
| `match_type` | `yes_yes`, `yes_maybe`, `maybe_maybe` |
| `feedback_type` | `bug`, `feature_request`, `general` |
| `feedback_status` | `new`, `reviewed`, `in_progress`, `resolved`, `closed` |
| `subscription_status` | `active`, `cancelled`, `expired`, `billing_issue`, `paused` |
## Key Flows
### Response & Match Flow
1. User submits response via `submit-response` edge function
2. Response saved to `responses` table (UPSERT on `user_id, question_id`)
3. Function checks for partner's response on same question
4. If both answered positively (not "no"), a `match` is created
5. Match triggers push notification to both partners
6. Match unlocks chat thread in `messages` table
### Couple Pairing Flow
1. First user creates couple, gets `invite_code`
2. Second user joins via `manage-couple` edge function with invite code
3. Both profiles' `couple_id` set to same couple
### Pack Selection Flow
1. Couples can enable/disable packs via `couple_packs` junction table
2. `get_recommended_questions()` function filters questions by enabled packs
3. Two-part questions show `partner_text` to second responder
+277
View File
@@ -0,0 +1,277 @@
# Subscription & Premium System
This document explains how subscriptions and premium access work across the client, server, and database layers.
## Overview
Sauci uses RevenueCat for subscription management with a multi-layer architecture:
```
┌─────────────────┐ ┌──────────────────┐ ┌─────────────────┐
│ Mobile App │────▶│ RevenueCat │────▶│ Supabase Edge │
│ (RevenueCat │ │ Service │ │ Functions │
│ SDK) │◀────│ │◀────│ │
└─────────────────┘ └──────────────────┘ └─────────────────┘
│
▼
┌─────────────────┐
│ Database │
│ (is_premium) │
└─────────────────┘
```
## Components
### 1. Client SDK (`src/lib/revenuecat.ts`)
The `RevenueCatService` class wraps the `react-native-purchases` SDK.
**Platform Support:**
- iOS: Full support with native SDK
- Android: Not yet implemented
- Web/Expo Go: Gracefully disabled (returns defaults)
**Key Methods:**
| Method | Purpose |
|--------|---------|
| `initialize(userId)` | Configure SDK with API key and user ID |
| `login(userId)` | Associate purchases with Supabase user |
| `getOfferings()` | Fetch available subscription packages |
| `purchasePackage(pkg)` | Initiate purchase flow |
| `restorePurchases()` | Restore previous purchases |
| `getCustomerInfo()` | Get current subscription status |
| `parseCustomerInfo()` | Convert to `SubscriptionState` |
**Entitlement Check:**
```typescript
const entitlement = customerInfo.entitlements.active[ENTITLEMENT_ID];
return {
isProUser: !!entitlement,
activeSubscription: entitlement?.productIdentifier || null,
expirationDate: entitlement?.expirationDate ? new Date(...) : null,
willRenew: entitlement?.willRenew || false,
};
```
### 2. Zustand Store (`src/store/index.ts`)
The `useSubscriptionStore` manages subscription state in the app:
```typescript
interface SubscriptionStoreState {
subscription: SubscriptionState;
offerings: PurchasesOffering | null;
isLoadingOfferings: boolean;
isPurchasing: boolean;
error: string | null;
}
```
**Key Actions:**
- `initializeRevenueCat(userId)` - Called after auth, sets up SDK
- `fetchOfferings()` - Loads available packages for paywall
- `purchasePackage(pkg)` - Handles purchase with error handling
- `restorePurchases()` - Restores and syncs to server
- `refreshSubscriptionStatus()` - Checks RevenueCat and syncs to server
### 3. Webhook Handler (`functions/revenuecat-webhook`)
RevenueCat sends webhook events for subscription lifecycle changes.
**Event Types Handled:**
| Event | Status Set |
|-------|------------|
| `INITIAL_PURCHASE` | `active` |
| `RENEWAL` | `active` |
| `UNCANCELLATION` | `active` |
| `PRODUCT_CHANGE` | `active` |
| `CANCELLATION` | `cancelled` |
| `EXPIRATION` | `expired` |
| `BILLING_ISSUE` | `billing_issue` |
| `SUBSCRIPTION_PAUSED` | `paused` |
**Idempotency:**
Events are logged to `revenuecat_webhook_events` table with unique `event_id` to prevent duplicate processing.
**Flow:**
```
1. Verify webhook secret (Authorization header)
2. Check for duplicate event_id
3. Record event in webhook_events table
4. Upsert subscription record
5. Database trigger syncs is_premium to profiles
```
### 4. Sync Function (`functions/sync-subscription`)
Called from client to verify subscription status server-side.
**When Called:**
- After purchase completes
- On app foreground (if status differs)
- Manual restore purchases
**Flow:**
```
1. Verify user JWT
2. Call RevenueCat API: GET /v1/subscribers/{user_id}
3. Check entitlement expiration date
4. Update profiles.is_premium
```
### 5. Database Layer
**Tables:**
```sql
-- Main subscription record
subscriptions (
user_id UUID FK profiles,
product_id TEXT,
status subscription_status, -- active/cancelled/expired/billing_issue/paused
expires_at TIMESTAMPTZ,
...
)
-- Idempotency tracking
revenuecat_webhook_events (
event_id TEXT UNIQUE,
event_type TEXT,
app_user_id TEXT,
payload JSONB
)
```
**Auto-Sync Trigger:**
```sql
CREATE TRIGGER sync_premium_on_subscription_change
AFTER INSERT OR UPDATE ON public.subscriptions
FOR EACH ROW EXECUTE FUNCTION public.sync_premium_status();
```
This trigger updates `profiles.is_premium` based on active subscription status.
## Premium Access Logic
### Partner Premium Sharing
If either partner in a couple has premium, both get access:
```sql
CREATE FUNCTION public.has_premium_access(check_user_id UUID)
RETURNS BOOLEAN AS $$
-- Returns TRUE if user OR their partner has is_premium = TRUE
$$;
```
### RLS Policies
Premium packs are filtered at the database level:
```sql
CREATE POLICY "Users or partners with premium can view premium packs"
ON public.question_packs FOR SELECT
USING (
is_premium = FALSE OR
EXISTS (
SELECT 1 FROM profiles p WHERE p.id = auth.uid()
AND (p.is_premium = TRUE OR EXISTS (
SELECT 1 FROM profiles partner
WHERE partner.couple_id = p.couple_id
AND partner.is_premium = TRUE
))
)
);
```
### Question Filtering
The `get_recommended_questions()` function also filters premium packs:
```sql
-- Only include packs where:
(qp.is_premium = FALSE OR v_has_premium = TRUE)
```
## Environment Variables
| Variable | Location | Purpose |
|----------|----------|---------|
| `EXPO_PUBLIC_REVENUECAT_IOS_API_KEY` | Mobile app | RevenueCat public SDK key |
| `EXPO_PUBLIC_REVENUECAT_ENTITLEMENT_ID` | Mobile app | Entitlement identifier (default: "pro") |
| `REVENUECAT_API_KEY` | Edge functions | RevenueCat secret API key |
| `REVENUECAT_ENTITLEMENT_ID` | Edge functions | Server-side entitlement check |
| `REVENUECAT_WEBHOOK_SECRET` | Edge functions | Webhook auth verification |
## Subscription Flow Diagrams
### Purchase Flow
```
User taps Subscribe
│
▼
┌───────────────────┐
│ fetchOfferings() │
└─────────┬─────────┘
▼
┌───────────────────┐
│ Display Paywall │
└─────────┬─────────┘
▼
┌───────────────────┐
│ purchasePackage() │──▶ Native App Store UI
└─────────┬─────────┘
▼
┌───────────────────┐
│ RevenueCat SDK │──▶ Sends webhook to server
│ returns success │
└─────────┬─────────┘
▼
┌───────────────────┐
│ refreshStatus() │──▶ Calls sync-subscription
└─────────┬─────────┘
▼
┌───────────────────┐
│ fetchUser() │──▶ UI shows premium features
└───────────────────┘
```
### Webhook Flow
```
RevenueCat Event (e.g., RENEWAL)
│
▼
┌───────────────────────┐
│ revenuecat-webhook │
│ Edge Function │
└─────────┬─────────────┘
│
├──▶ Check event_id (idempotency)
│
├──▶ Record in webhook_events
│
▼
┌───────────────────────┐
│ UPSERT subscriptions │
└─────────┬─────────────┘
│
▼
┌───────────────────────┐
│ sync_premium_status() │──▶ Updates profiles.is_premium
│ (Database Trigger) │
└───────────────────────┘
```
## Testing
RevenueCat sandbox mode is detected via `event.environment !== "PRODUCTION"` and stored in `subscriptions.is_sandbox`.
To test webhooks locally:
```bash
# Use ngrok or similar to expose local Supabase
supabase functions serve revenuecat-webhook --env-file .env.local
```