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

9.5 KiB

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):

// Request
{ }

// Response
{ success: true, couple_id: "uuid", invite_code: "abc12345" }

Join Couple (with invite code):

// 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).

// 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

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)
-- 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:

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:

// 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:

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

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"