Files
Sauci/docs/couple-pairing.md
T
jjbruntonandClaude Fable 5 d48b44baa8 feat(invite-funnel): add web join page and mobile deep-link hand-off
Adds a shareable https://sauci.app/join/{code} link as an alternative to
manually typing an 8-character invite code:

- apps/web: /join/[code] page that validates the code shape client-side,
  attempts the app.sauci:// scheme link, and falls back to a large code
  display with copy button plus App Store/Play Store links; PostHog events
  for page view, code copy, and store-button clicks.
- apps/web: apple-app-site-association and assetlinks.json under
  /.well-known so iOS Universal Links and Android App Links can verify
  sauci.app for /join/*. The Android release cert SHA-256 fingerprint is a
  placeholder pending the real value.
- apps/mobile: associated domains (iOS) and an intent filter (Android) for
  https://sauci.app/join/*, alongside the existing app.sauci scheme.
- apps/mobile: parse incoming join links, pre-fill and highlight the code on
  the pairing screen, stash the code for signed-out/mid-onboarding users and
  apply it once they reach pairing, and offer a once-per-session clipboard
  hand-off that is never auto-applied.
- apps/mobile: share message now leads with the join link, keeping the raw
  code visible as a fallback.
- Analytics: invite_link_opened, pairing_code_prefilled (mobile);
  join_page_viewed, join_page_code_copied, join_page_store_button_clicked
  (web).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-30 17:58:43 +01:00

337 lines
9.5 KiB
Markdown

# 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
```
## Invite Funnel (Join Links)
The invite code can also be delivered as a tappable link instead of requiring
the recipient to type it in manually.
**Link forms:**
- Universal/App Link: `https://sauci.app/join/{code}` (served by `apps/web`,
opens the app directly on a device that has it installed and verified).
- Custom scheme: `app.sauci://join?code={code}` (used by the web join page's
automatic hand-off attempt, and remains supported for any existing client
behavior).
**Web fallback (`apps/web/app/join/[code]/page.tsx`):** validates the code
shape client-side only (8-character alphanumeric; never calls a private API),
attempts the custom scheme link, and otherwise shows the code with a copy
button plus App Store / Play Store links. Copying only copies the raw code.
**Association files:** `apps/web/public/.well-known/apple-app-site-association`
and `apps/web/public/.well-known/assetlinks.json` authorize `sauci.app` to open
the app for `/join/*`. The Apple Team ID (`BXVPBATDZY`, from
`apps/mobile/app.json` and `apps/mobile/eas.json`) and bundle/package id
(`com.sauci.app`) are real values already used elsewhere in the repo. The
Android release certificate SHA-256 fingerprint in `assetlinks.json` is a
placeholder (`PLACEHOLDER_ANDROID_RELEASE_SHA256_CERT_FINGERPRINT`) and must
be replaced with the real release signing certificate fingerprint before
Android App Links will verify.
**Mobile link handling:** `apps/mobile/app.json` declares
`associatedDomains: ["applinks:sauci.app"]` (iOS) and an intent filter for
`https://sauci.app/join/*` (Android), in addition to the existing `app.sauci`
scheme. `apps/mobile/app/_layout.tsx` parses incoming URLs
(`src/lib/inviteLink.ts`) and either routes a signed-in, onboarded user
straight to the pairing screen with the code pre-filled, or stashes the code
(`src/lib/pendingInviteCode.ts`) for signed-out/mid-onboarding users. The
stashed code is applied once the user reaches the pairing screen or the app's
initial route (`apps/mobile/app/index.tsx`) after sign-in/onboarding
completes.
**Deferred clipboard hand-off:** if an unpaired user opens the pairing screen
with no code from a link or stash, the app checks the clipboard once per
session (`src/lib/clipboardInviteOffer.ts`) for a string matching the invite
code shape and offers to use it ("Use code XXXXXXXX from your clipboard?").
The code is never applied automatically; the user must accept the offer.
**Analytics:** `invite_link_opened` (`source`: `universal_link` | `scheme` |
`clipboard`), `pairing_code_prefilled` (mobile, Firebase Analytics), and
`join_page_viewed` / `join_page_code_copied` / `join_page_store_button_clicked`
(web, PostHog).
## 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" |