mirror of
https://github.com/jjbrunton/Sauci.git
synced 2026-10-31 18:19:43 +00:00
262 lines
8.6 KiB
Markdown
262 lines
8.6 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"
|
|
text question_type "swipe/text_answer/audio/photo/who_likely"
|
|
jsonb config "type-specific configuration"
|
|
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"
|
|
jsonb response_data "non-swipe answer payload"
|
|
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/both_answered"
|
|
jsonb response_summary "both non-swipe payloads by user"
|
|
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` |
|
|
| `question_type` | `swipe`, `text_answer`, `audio`, `photo`, `who_likely` |
|
|
| `match_type` | `yes_yes`, `yes_maybe`, `maybe_maybe`, `both_answered` |
|
|
| `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
|
|
|
|
### Question interactions
|
|
|
|
`questions.question_type` defaults to `swipe`; `questions.config` holds
|
|
type-specific settings. A non-swipe response keeps its payload in
|
|
`responses.response_data`. When a non-swipe interaction resolves as
|
|
`both_answered`, `matches.response_summary` holds both payloads keyed by user
|
|
ID. The complete interaction and match contract is maintained in
|
|
[Question interaction types](question-types.md).
|
|
|
|
### Response & Match Flow
|
|
1. User submits a response to the standalone API at `POST /v1/responses`
|
|
2. Response saved to `responses` table (UPSERT on `user_id, question_id`)
|
|
3. API checks for the partner's response on the same question
|
|
4. API applies the [question interaction contract](question-types.md) to create,
|
|
update, or remove a `match`
|
|
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.
|