mirror of
https://github.com/jjbrunton/Sauci.git
synced 2026-10-31 18: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,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