Files
Sauci/docs/schema.md
T
jjbruntonandClaude Fable 5 09255da7db feat(pairing): solo sealed answers that unlock on pairing
Unpaired users could only reach a waiting-room dead end and could not answer
questions until a partner joined. Responses.couple_id is now nullable so a
solo user answers immediately and banks "sealed answers" (couple_id IS NULL).
Pairing claims both members' sealed answers and computes matches for every
question both have now answered. Invite copy and the unpaired push nudge
escalate with the sealed answer count.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-30 19:34:42 +01:00

247 lines
7.8 KiB
Markdown

# 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
content_review_status content_status "allowed required for customer access"
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"
text[] allowed_couple_genders "couple composition filter"
text[] target_user_genders "individual user filter"
text[] required_props "props/accessories required"
content_review_status content_status "allowed required for customer access"
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 "nullable: null is a sealed solo answer, claimed at pairing"
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` |
| `content_review_status` | `unreviewed`, `allowed`, `archived` |
| `content_entity_type` | `categories`, `question_packs`, `questions`, `dare_packs`, `dares` |
## Catalogue review metadata
`categories`, `question_packs`, `questions`, `dare_packs`, and `dares` carry
`content_status`, `content_review_reason`, `content_reviewed_at`, and
`content_reviewed_by`. Insert triggers force new rows to `unreviewed` with empty
review metadata even when a caller supplies forged values. Status changes and
same-status re-reviews require a non-blank reason, only super-admin users may
make an authenticated decision, reviewer identity/time are database-managed,
and visible content edits reset an unchanged decision to `unreviewed`.
`content_reviews` is the append-only status-change history. Only super-admins
may read it through the authenticated API. Customer catalogue RLS and the legacy
recommendation/teaser RPCs fail closed: a category, pack, and question or dare
must all be `allowed`. `unreviewed` and `archived` content remains available to
administrators but is not returned to customer clients. Service-role response
functions independently reject questions unless both question and pack are
allowed.
## 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
Without a couple yet, step 3 has no partner response to check: the response is
saved with `couple_id IS NULL` (a sealed answer) and no match is created. See
[Solo answering and sealed answers](couple-pairing.md#solo-answering-and-sealed-answers).
### 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
4. Every sealed answer (`couple_id IS NULL`) belonging to either member is
claimed into the couple, and matches are computed for every question both
members have now answered
### Pack Selection Flow
1. Couples can enable/disable packs via `couple_packs` junction table
2. `get_recommended_questions()` filters by enabled packs and the universal
allowed-content boundary
3. Two-part questions show `partner_text` to second responder
### Question Targeting System
Questions can be targeted at specific audiences using two complementary filters:
| Field | Purpose | Level | Example |
|-------|---------|-------|---------|
| `allowed_couple_genders` | Couple composition filter | Couple | `['male+female', 'female+female']` |
| `target_user_genders` | Individual user filter | User | `['female', 'non-binary']` |
**How targeting works:**
1. **Couple filter** (`allowed_couple_genders`): Questions only shown to couples matching the composition (e.g., same-sex specific questions)
2. **User filter** (`target_user_genders`): Controls who sees the question **as initiator** (first responder)
- If user's gender matches → they can see and answer first
- If user's gender doesn't match → they only see it after partner answers (via `partner_text`)
**Example flow:**
```
Question:
text: "Have your partner suck your tits"
partner_text: "Suck your partner's tits"
target_user_genders: ['female']
1. Female sees "Have your partner suck your tits" → swipes YES
2. Male sees "Suck your partner's tits" (partner_text, unlocked by her answer)
3. Both answered → Match created!
```
This ensures gender-appropriate phrasing while still allowing both partners to respond.