Compare commits

...
9 Commits
Author SHA1 Message Date
jjbrunton c8aaa8967d feat: add catchup and unpaired reminder notifications
Add automatic push notifications for behind partners (escalating: 24h then
every 3 days) and daily unpaired user reminders. Includes edge functions,
migrations, notification preference toggles, and UI improvements to pairing
and swipe screens.
2026-01-31 23:38:27 +00:00
jjbruntonandClaude Opus 4.5 8a44fef764 fix(admin): remove enabled packs filter from outstanding questions query
The couple_packs table has no admin RLS policy, so the enabled packs
query returns empty for admin users. Query all partner-answered
questions instead — more useful for admin visibility anyway.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-31 22:58:21 +00:00
jjbruntonandClaude Opus 4.5 edb2cedda0 feat(admin): add outstanding questions tab and gap status to user detail
Shows questions the partner has answered that this user hasn't yet,
with intensity levels and partner's answer. Adds outstanding count
badge and gap-blocked indicator to the profile header.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-31 22:52:21 +00:00
jjbruntonandClaude Opus 4.5 73d7c1a200 docs: add strong guardrails against MCP migrations in CLAUDE.md and AGENTS.md
Prevents agents from using apply_migration or DDL via execute_sql,
which breaks CI by creating remote migration entries without local files.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-31 22:21:48 +00:00
jjbruntonandClaude Opus 4.5 307af2c1e9 fix(ci): restore --include-all after backfilling prod migration history
All 57 pre-existing migrations have been registered in prod's
schema_migrations table so db push --include-all will skip them
and only apply new migrations.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-31 22:15:36 +00:00
jjbruntonandClaude Opus 4.5 057c682c6c fix(ci): remove --include-all from db push to prevent re-applying old migrations
Production only had one migration tracked in schema_migrations.
With --include-all, db push tried to re-run all 57 earlier migrations.
Without it, only migrations newer than the latest tracked version are applied.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-31 22:08:58 +00:00
jjbruntonandClaude Opus 4.5 e29183f104 ci: re-trigger deployment after fixing prod migration history
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-31 22:08:21 +00:00
jjbruntonandClaude Opus 4.5 f07f80b529 fix(ci): add missing discord notification migration file
Migration 20260127202703 was applied to prod via MCP but had no local
file, causing supabase db push to fail with a version mismatch.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-31 22:05:19 +00:00
jjbruntonandClaude Opus 4.5 25db338861 feat: add weekly summary notifications
Adds Sunday weekly recap push notifications summarizing couple match
activity. Includes edge function, migration, notification preference
toggle, and mobile notification handling.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-31 21:56:32 +00:00
18 changed files with 1703 additions and 226 deletions

No files matched your search

+123 -71
View File
@@ -1,6 +1,6 @@
--- ---
allowed-tools: Read, mcp__sauci-prod__execute_sql, mcp__sauci-prod__list_tables allowed-tools: Read, mcp__sauci-prod__execute_sql, mcp__sauci-prod__list_tables
argument-hint: <pack-name> --category=<category> --intensity=<min-max> [--count=30] [--premium] argument-hint: <pack-name> --category=<category> [--count=30] [--premium] [--types=swipe,who_likely,audio,photo]
description: Generate a question pack for Sauci following established content guidelines description: Generate a question pack for Sauci following established content guidelines
--- ---
@@ -18,6 +18,7 @@ First, read the pack generation guidelines:
- Existing categories: can be retrieved from supabase with query: SELECT name, id FROM categories ORDER BY sort_order - Existing categories: can be retrieved from supabase with query: SELECT name, id FROM categories ORDER BY sort_order
- Existing packs in target category: Will query once category is identified - Existing packs in target category: Will query once category is identified
- Existing props: can be retrieved with: SELECT DISTINCT unnest(required_props) as prop FROM questions WHERE required_props IS NOT NULL ORDER BY prop - Existing props: can be retrieved with: SELECT DISTINCT unnest(required_props) as prop FROM questions WHERE required_props IS NOT NULL ORDER BY prop
- Current question type distribution in target pack (if adding to existing): SELECT question_type, count(*) FROM questions WHERE pack_id = '[pack_id]' AND deleted_at IS NULL GROUP BY question_type
## Task ## Task
@@ -28,26 +29,28 @@ Generate a complete question pack following all Sauci content guidelines.
Extract from $ARGUMENTS: Extract from $ARGUMENTS:
- **Pack name**: Required - the name for this pack - **Pack name**: Required - the name for this pack
- **Category**: Required - which category this belongs to (or "new" to create) - **Category**: Required - which category this belongs to (or "new" to create)
- **Intensity range**: Required - e.g., "2-3", "3-4", "4-5"
- **Question count**: Optional - default 30, can be 25-50 - **Question count**: Optional - default 30, can be 25-50
- **Premium**: Optional flag - if pack should be premium - **Premium**: Optional flag - if pack should be premium
- **Types**: Optional - comma-separated question types to generate. Default: `swipe,who_likely,audio,photo`. Options: `swipe`, `who_likely`, `audio`, `photo`
### Step 2: Understand Context ### Step 2: Understand Context
1. If category exists, query existing packs to understand progression: 1. If category exists, query existing packs to understand what's there:
- What packs already exist? - What packs already exist?
- What intensity levels are covered? - What question types are represented?
- Where does this new pack fit in the progression? - Where does this new pack fit?
2. Check if category is explicit: 2. Check if category is explicit:
```sql ```sql
SELECT id, name, is_explicit FROM categories WHERE name ILIKE '%<category>%'; SELECT id, name, is_explicit FROM categories WHERE name ILIKE '%<category>%';
``` ```
3. If new category, establish the progression plan: 3. Check the current type distribution if adding to an existing pack:
- What will the beginner pack cover? ```sql
- What will intermediate cover? SELECT question_type, count(*) FROM questions
- What will advanced cover? WHERE pack_id = '[pack_id]' AND deleted_at IS NULL
GROUP BY question_type ORDER BY count DESC;
```
### Step 3: Generate Questions ### Step 3: Generate Questions
@@ -74,9 +77,60 @@ Follow these rules strictly:
**For NON-EXPLICIT categories (is_explicit = false):** **For NON-EXPLICIT categories (is_explicit = false):**
- CRITICAL: No sexual acts, crude language, or NSFW content - CRITICAL: No sexual acts, crude language, or NSFW content
- Keep language romantic, sensual, or playful without graphic terms - Keep language romantic, sensual, or playful without graphic terms
- If intensity 3+ activities are needed, use suggestive but not explicit language
#### 3.3 Partner Text Rules #### 3.3 Question Types
Each question MUST have a `question_type`. The default mix should include variety across types.
**`swipe`** (default) - Standard yes/no/maybe proposal
- The classic Sauci format
- Can be symmetric or asymmetric (with partner_text + inverse)
- Example: "Give your partner a back massage"
**`who_likely`** - "Who's more likely to..." format
- ALWAYS symmetric (partner_text = NULL, no inverse needed)
- Both partners pick who they think is more likely
- Match created when both answer
- Format: "Who is more likely to [activity/trait]?"
- Keep fun and lighthearted — these are conversation starters
- Examples:
- "Who is more likely to cry during a movie?"
- "Who is more likely to plan a surprise date?"
- "Who is more likely to fall asleep first?"
**`audio`** - Voice recording prompt
- ALWAYS symmetric (partner_text = NULL, no inverse needed)
- Partners record a voice message in response
- `config`: `{"max_duration_seconds": 60}` (default 60)
- Great for: confessions, storytelling, impressions, compliments, singing
- Format: prompts that invite a spoken response
- Examples:
- "Record your best impression of your partner"
- "Describe your favourite memory together"
- "Say something you've never told your partner"
**`photo`** - Photo prompt
- ALWAYS symmetric (partner_text = NULL, no inverse needed)
- Partners take or share a photo in response
- No special config needed
- Great for: selfies, throwbacks, showing something, creative challenges
- Format: prompts that invite a photo response
- Examples:
- "Share a photo from your camera roll that makes you think of your partner"
- "Take a selfie showing how you feel right now"
- "Share a screenshot of your last text about your partner"
#### 3.4 Type Distribution Guidelines
For a standard 30-question pack, aim for roughly:
- **20-22 swipe** questions (the core experience)
- **4-5 who_likely** questions
- **2-3 audio** questions
- **2-3 photo** questions
Adjust based on what fits the pack's theme. Some packs may lean heavier on certain types.
#### 3.5 Partner Text Rules (swipe questions only)
- **NULL** for symmetric activities (both partners do the same thing together) - **NULL** for symmetric activities (both partners do the same thing together)
- **Filled** for asymmetric activities (one does something to/for the other) - **Filled** for asymmetric activities (one does something to/for the other)
@@ -91,9 +145,11 @@ Follow these rules strictly:
- BAD: "Receive oral from your partner" (clinical) - BAD: "Receive oral from your partner" (clinical)
- GOOD: "Let your partner pleasure you with their mouth" (enticing) - GOOD: "Let your partner pleasure you with their mouth" (enticing)
#### 3.4 Critical: Inverse Questions and Database Linking #### 3.6 Critical: Inverse Questions and Database Linking (swipe questions only)
For EVERY asymmetric question, create its inverse AND link them using the `inverse_of` column: Non-swipe types (who_likely, audio, photo) are ALWAYS symmetric — no inverse needed.
For EVERY asymmetric **swipe** question, create its inverse AND link them using the `inverse_of` column:
``` ```
Original (PRIMARY - inverse_of = NULL): Original (PRIMARY - inverse_of = NULL):
@@ -114,12 +170,9 @@ This ensures:
2. The database tracks which questions are inverses of each other 2. The database tracks which questions are inverses of each other
3. Accurate unique question counts (200 questions = 100 unique concepts with inverses) 3. Accurate unique question counts (200 questions = 100 unique concepts with inverses)
**Database Schema:** #### 3.7 Intensity Guidelines
- `inverse_of` column: UUID referencing the primary question's ID
- Primary questions have `inverse_of = NULL`
- Inverse questions have `inverse_of = <primary_question_id>`
#### 3.5 Intensity Guidelines Each question still needs an `intensity` value (1-5) for backend filtering, even though it's not shown in the UI.
| Level | Name | Description | Examples | | Level | Name | Description | Examples |
|-------|------|-------------|----------| |-------|------|-------------|----------|
@@ -129,7 +182,7 @@ This ensures:
| 4 | Steamy | Explicit sex, moderate adventure | intercourse, light bondage, anal play | | 4 | Steamy | Explicit sex, moderate adventure | intercourse, light bondage, anal play |
| 5 | Intense | Advanced/BDSM/extreme | impact play, power dynamics, taboo kinks | | 5 | Intense | Advanced/BDSM/extreme | impact play, power dynamics, taboo kinks |
#### 3.6 Targeting Rules #### 3.8 Targeting Rules
**Couple Targeting (allowed_couple_genders):** **Couple Targeting (allowed_couple_genders):**
- DEFAULT: null (ALL couples) unless explicit anatomical requirement - DEFAULT: null (ALL couples) unless explicit anatomical requirement
@@ -140,19 +193,13 @@ This ensures:
**Initiator Targeting (target_user_genders):** **Initiator Targeting (target_user_genders):**
- DEFAULT: null (anyone can initiate) - DEFAULT: null (anyone can initiate)
- Only for asymmetric questions (those with partner_text) - Only for asymmetric swipe questions (those with partner_text)
- Set based on who does the action in "text" field: - Set based on who does the action in "text" field
- "Swallow your partner's cum" -> partner has penis -> initiator: `['female']` in M+F couples
- "Deep throat your partner" -> partner has penis -> `['female']` in M+F couples
- "Give your partner a massage" -> gender-neutral -> null
- When in doubt, use null - When in doubt, use null
**Anatomical Consistency:** **Note:** who_likely, audio, and photo questions are always NULL for both targeting fields (they're symmetric and gender-neutral).
- NEVER mix male-specific and female-specific acts as alternatives
- BAD: "Finger or give your partner a handjob" (mixed anatomy)
- GOOD: Pick one activity per question
#### 3.7 Required Props #### 3.9 Required Props
Identify physical items/accessories needed: Identify physical items/accessories needed:
- Props are items like: blindfold, remote vibrator, handcuffs, massage oil, ice cubes - Props are items like: blindfold, remote vibrator, handcuffs, massage oil, ice cubes
@@ -167,15 +214,22 @@ Before outputting, verify each question against this checklist:
**Language & Format:** **Language & Format:**
- [ ] Uses "your partner" (no gendered pronouns) - [ ] Uses "your partner" (no gendered pronouns)
- [ ] Is a proposal, not an interview question - [ ] Swipe questions are proposals, not interview questions
- [ ] No wishy-washy language - [ ] No wishy-washy language
- [ ] No time-specific words - [ ] No time-specific words
- [ ] 5-12 words (concise) - [ ] 5-12 words (concise)
- [ ] No cheesy/cliche language - [ ] No cheesy/cliche language
- [ ] Varied sentence structures - [ ] Varied sentence structures
**Question Types:**
- [ ] who_likely questions use "Who is more likely to..." format
- [ ] audio questions prompt a natural spoken response
- [ ] photo questions prompt something visual and shareable
- [ ] Non-swipe types have partner_text = NULL and no inverse
- [ ] Type distribution is balanced (see 3.4)
**Structure:** **Structure:**
- [ ] All asymmetric questions have inverses (these are NOT duplicates - they're required pairs) - [ ] All asymmetric swipe questions have inverses (these are NOT duplicates - they're required pairs)
- [ ] Partner text is appealing (not clinical) - [ ] Partner text is appealing (not clinical)
- [ ] Partner text frames responses as "allowing" not forced - [ ] Partner text frames responses as "allowing" not forced
- [ ] No actual duplicates (same `text` appearing twice) - [ ] No actual duplicates (same `text` appearing twice)
@@ -190,7 +244,7 @@ Before outputting, verify each question against this checklist:
**Consistency:** **Consistency:**
- [ ] No mixed anatomy in alternatives - [ ] No mixed anatomy in alternatives
- [ ] Tone matches explicit/non-explicit category - [ ] Tone matches explicit/non-explicit category
- [ ] Fits progression within category - [ ] Fits within the category
### Step 5: Output ### Step 5: Output
@@ -207,55 +261,50 @@ VALUES (
) )
RETURNING id; RETURNING id;
-- Then insert questions with inverse_of linking -- Then insert questions
-- For inverse pairs: insert primary first (inverse_of = NULL), then inverse pointing to primary -- For swipe inverse pairs: insert primary first (inverse_of = NULL), then inverse pointing to primary
-- Generate UUIDs for inverse pairs upfront -- Swipe pair example
-- Pair 1: Primary and its inverse
WITH pair1_primary AS ( WITH pair1_primary AS (
INSERT INTO questions (pack_id, text, partner_text, intensity, inverse_of) INSERT INTO questions (pack_id, text, partner_text, intensity, question_type, config, inverse_of)
VALUES ('[pack_id]', 'Spank your partner', 'Be spanked by your partner', 3, NULL) VALUES ('[pack_id]', 'Spank your partner', 'Be spanked by your partner', 3, 'swipe', '{}', NULL)
RETURNING id RETURNING id
) )
INSERT INTO questions (pack_id, text, partner_text, intensity, inverse_of) INSERT INTO questions (pack_id, text, partner_text, intensity, question_type, config, inverse_of)
SELECT '[pack_id]', 'Be spanked by your partner', 'Spank your partner', 3, id SELECT '[pack_id]', 'Be spanked by your partner', 'Spank your partner', 3, 'swipe', '{}', id
FROM pair1_primary; FROM pair1_primary;
-- Symmetric questions (no inverse_of needed) -- Symmetric swipe (no inverse needed)
INSERT INTO questions (pack_id, text, partner_text, intensity, allowed_couple_genders, target_user_genders, required_props, inverse_of) VALUES INSERT INTO questions (pack_id, text, partner_text, intensity, question_type, config, inverse_of) VALUES
('[pack_id]', 'Symmetric question text', NULL, 2, NULL, NULL, NULL, NULL); ('[pack_id]', 'Cook dinner together', NULL, 1, 'swipe', '{}', NULL);
-- Or bulk insert with explicit UUIDs for pairs: -- who_likely questions (always symmetric, no inverse)
INSERT INTO questions (id, pack_id, text, partner_text, intensity, inverse_of) VALUES INSERT INTO questions (pack_id, text, partner_text, intensity, question_type, config, inverse_of) VALUES
-- Pair 1 ('[pack_id]', 'Who is more likely to plan a surprise date?', NULL, 1, 'who_likely', '{}', NULL),
('uuid-primary-1', '[pack_id]', 'Give your partner a massage', 'Receive a massage from your partner', 2, NULL), ('[pack_id]', 'Who is more likely to cry during a movie?', NULL, 1, 'who_likely', '{}', NULL);
('uuid-inverse-1', '[pack_id]', 'Receive a massage from your partner', 'Give your partner a massage', 2, 'uuid-primary-1'),
-- Pair 2 -- audio questions (always symmetric, no inverse)
('uuid-primary-2', '[pack_id]', 'Blindfold your partner', 'Be blindfolded by your partner', 3, NULL), INSERT INTO questions (pack_id, text, partner_text, intensity, question_type, config, inverse_of) VALUES
('uuid-inverse-2', '[pack_id]', 'Be blindfolded by your partner', 'Blindfold your partner', 3, 'uuid-primary-2'), ('[pack_id]', 'Describe your favourite memory together', NULL, 1, 'audio', '{"max_duration_seconds": 60}', NULL),
-- Symmetric (no pair) ('[pack_id]', 'Record your best impression of your partner', NULL, 1, 'audio', '{"max_duration_seconds": 60}', NULL);
(gen_random_uuid(), '[pack_id]', 'Cook dinner together', NULL, 1, NULL);
-- photo questions (always symmetric, no inverse)
INSERT INTO questions (pack_id, text, partner_text, intensity, question_type, config, inverse_of) VALUES
('[pack_id]', 'Share a photo that makes you think of your partner', NULL, 1, 'photo', '{}', NULL);
``` ```
**Field notes:**
- `inverse_of`: NULL for primary/symmetric questions, UUID of primary question for inverses
- `allowed_couple_genders`: NULL for all couples, or array like `ARRAY['male+male', 'female+male']`
- `target_user_genders`: NULL for any initiator, or array like `ARRAY['female']`
- `required_props`: NULL if no props needed, or array like `ARRAY['blindfold', 'ice cubes']`
Also provide a summary table: Also provide a summary table:
| Metric | Value | | Metric | Value |
|--------|-------| |--------|-------|
| Total questions | X | | Total questions | X |
| Unique question concepts | X (total - inverse count) | | Unique question concepts | X (total - inverse count) |
| Intensity 1 | X | | Swipe questions | X |
| Intensity 2 | X | | who_likely questions | X |
| Intensity 3 | X | | audio questions | X |
| Intensity 4 | X | | photo questions | X |
| Intensity 5 | X |
| Symmetric (null partner_text) | X | | Symmetric (null partner_text) | X |
| Asymmetric pairs (with inverse_of links) | X pairs (X questions) | | Asymmetric swipe pairs (with inverse_of links) | X pairs (X questions) |
| With couple targeting | X | | With couple targeting | X |
| With initiator targeting | X | | With initiator targeting | X |
| With required props | X | | With required props | X |
@@ -263,21 +312,24 @@ Also provide a summary table:
## Example Usage ## Example Usage
``` ```
/generate-pack "Testing the Waters" --category="Public Thrills" --intensity=2-3 --count=30 /generate-pack "Testing the Waters" --category="Public Thrills" --count=30
/generate-pack "Kink Discovery" --category="The Kink Lab" --intensity=3-5 --count=50 /generate-pack "Kink Discovery" --category="The Kink Lab" --count=50 --premium
/generate-pack "Staying Close" --category=new:"Long Distance" --intensity=1 --count=30 /generate-pack "Staying Close" --category=new:"Long Distance" --count=30
/generate-pack "Date Night" --category="Quality Time" --types=who_likely,audio,photo
``` ```
## Guidelines ## Guidelines
- DO ask clarifying questions if the pack concept is unclear - DO ask clarifying questions if the pack concept is unclear
- DO check existing packs in the category first - DO check existing packs in the category first
- DO create inverse questions for ALL asymmetric content AND link them with inverse_of - DO create inverse questions for ALL asymmetric swipe content AND link them with inverse_of
- DO include a mix of question types (swipe, who_likely, audio, photo)
- DO vary sentence structure and openers - DO vary sentence structure and openers
- DO use the inverse_of column to link inverse pairs (inverse points to primary's UUID) - DO use the inverse_of column to link inverse pairs (inverse points to primary's UUID)
- DON'T use cheesy or cliche language - DON'T use cheesy or cliche language
- DON'T assume heterosexual couples - DON'T assume heterosexual couples
- DON'T skip the inverse question requirement - DON'T skip the inverse question requirement for asymmetric swipe questions
- DON'T forget to set inverse_of - this is how we track unique question concepts - DON'T create inverses for who_likely, audio, or photo questions (they're always symmetric)
- DON'T forget to set question_type - every question needs one
- DON'T use interview-style questions ("Would you like to...") - DON'T use interview-style questions ("Would you like to...")
- DON'T treat inverse pairs as duplicates - they are REQUIRED (one's `text` = other's `partner_text`) - DON'T treat inverse pairs as duplicates - they are REQUIRED (one's `text` = other's `partner_text`)
+14
View File
@@ -5,3 +5,17 @@
- npm run lint - npm run lint
- npm run typecheck - npm run typecheck
- npm run build - npm run build
## Database Migrations - MANDATORY RULES
**NEVER use `mcp__sauci-prod__apply_migration` or `mcp__sauci-non-prod__apply_migration`.** These create migration records in the remote database without local files, which breaks CI/CD deployments.
**NEVER run DDL (`CREATE`, `ALTER`, `DROP`) via `mcp__*__execute_sql`.** This changes the schema with no migration tracking.
**All schema changes must be local migration files:**
1. Run `supabase migration new <name>` to create the file
2. Edit the file in `apps/supabase/migrations/`
3. Use idempotent SQL (`IF NOT EXISTS`, `CREATE OR REPLACE`)
4. Commit the file — CI deploys it automatically
**`execute_sql` is ONLY for SELECT queries.** No exceptions.
+46 -15
View File
@@ -67,36 +67,63 @@ When using Claude Code with MCP tools:
- Use `mcp__sauci-non-prod__*` tools for development/staging database operations - Use `mcp__sauci-non-prod__*` tools for development/staging database operations
- **Always verify which environment you're targeting before running destructive operations** - **Always verify which environment you're targeting before running destructive operations**
### Database Migrations - IMPORTANT ### Database Migrations - CRITICAL
**NEVER use MCP tools (`apply_migration`) for database migrations.** Always use the Supabase CLI instead. > **STOP: Read this entire section before making ANY database schema change.**
**Why:** MCP migrations are tracked in the database but do NOT create local migration files. This means: #### The Golden Rule
- Migration history is not committed to git
- Other developers won't get the schema changes **ALL schema changes MUST go through local migration files.** No exceptions.
- `supabase db reset` won't include MCP-applied migrations
- Changes cannot be reproduced from scratch #### Forbidden Actions
- **NEVER call `apply_migration` via MCP** (either `mcp__sauci-prod__apply_migration` or `mcp__sauci-non-prod__apply_migration`). This creates migration entries in the remote database with NO local file, which breaks `supabase db push` in CI and causes deployment failures that are painful to fix.
- **NEVER run DDL statements (`CREATE`, `ALTER`, `DROP`) via `execute_sql`**. This changes the schema without any migration tracking at all.
- **NEVER manually INSERT into `supabase_migrations.schema_migrations`** unless repairing a broken migration history (and only with explicit user approval).
#### Why This Matters
The CI/CD pipeline runs `supabase db push --include-all` which compares local migration files against the remote `schema_migrations` table. If the remote has migrations that don't exist locally, or local files exist without remote entries, the deployment **will fail** and block all subsequent deployments.
**A single `apply_migration` call can break deployments for the entire project.**
#### Correct Workflow for Schema Changes
**Correct workflow for schema changes:**
```bash ```bash
# 1. Create migration file locally # 1. Create migration file locally
supabase migration new <descriptive_name> supabase migration new <descriptive_name>
# 2. Edit the migration file in apps/supabase/migrations/ # 2. Edit the migration file in apps/supabase/migrations/
# 3. Test locally # 3. Make migrations idempotent where possible
# Use: CREATE OR REPLACE, IF NOT EXISTS, DROP IF EXISTS
# This prevents failures if re-run
# 4. Test locally
supabase db reset supabase db reset
# 4. Deploy to remote (link to correct project first) # 5. Commit the migration file to git
# 6. Deploy happens automatically via CI on push to master
# OR deploy manually:
supabase link --project-ref <project_ref> supabase link --project-ref <project_ref>
supabase db push supabase db push
``` ```
**MCP tools ARE appropriate for:** #### MCP Tools - What IS Allowed
- Reading data (`execute_sql` for SELECT queries)
- Exploring schema (`list_tables`, `list_migrations`) - **Reading data:** `execute_sql` with SELECT queries only
- Deploying edge functions (`deploy_edge_function`) - **Exploring schema:** `list_tables`, `list_migrations`, `list_extensions`
- Generating types (`generate_typescript_types`) - **Deploying edge functions:** `deploy_edge_function`
- **Generating types:** `generate_typescript_types`
- **Checking advisors:** `get_advisors`
#### If You Accidentally Used `apply_migration`
If a migration was applied via MCP, you must immediately:
1. Retrieve the migration SQL from `schema_migrations.statements`
2. Create a matching local file: `apps/supabase/migrations/<version>_<name>.sql`
3. Commit and push the file so CI can reconcile
## Architecture ## Architecture
@@ -135,6 +162,8 @@ Edge functions in `apps/supabase/functions/` handle complex operations:
- `delete-account` - Permanently deletes user account and all associated data (Apple App Store requirement) - `delete-account` - Permanently deletes user account and all associated data (Apple App Store requirement)
- `send-notification` - Push notifications for matches - `send-notification` - Push notifications for matches
- `send-nudge-notification` - Partner nudge notifications (rate limited to once per 12 hours) - `send-nudge-notification` - Partner nudge notifications (rate limited to once per 12 hours)
- `send-unpaired-reminder` - Daily cron reminder for unpaired users to share invite code
- `send-catchup-reminder` - Daily cron reminder for "behind" partners with escalating frequency
- `revenuecat-webhook` - Subscription webhook handler - `revenuecat-webhook` - Subscription webhook handler
- `sync-subscription` - Syncs RevenueCat subscription status - `sync-subscription` - Syncs RevenueCat subscription status
- `redeem-code` - Redeems promotional codes for premium access (website only) - `redeem-code` - Redeems promotional codes for premium access (website only)
@@ -167,6 +196,8 @@ This approach:
| `send-notification` | false | Internal trigger (no auth) | | `send-notification` | false | Internal trigger (no auth) |
| `send-message-notification` | false | Internal trigger (no auth) | | `send-message-notification` | false | Internal trigger (no auth) |
| `send-nudge-notification` | false | Manual via getUser() | | `send-nudge-notification` | false | Manual via getUser() |
| `send-unpaired-reminder` | false | Cron trigger (no auth) |
| `send-catchup-reminder` | false | Cron trigger (no auth) |
### Database Best Practices ### Database Best Practices
- **Always use `.maybeSingle()` instead of `.single()`** when the row might not exist. `.single()` throws an error if 0 or >1 rows are returned. - **Always use `.maybeSingle()` instead of `.single()`** when the row might not exist. `.single()` throws an error if 0 or >1 rows are returned.
+168 -127
View File
@@ -33,18 +33,78 @@ You are a content generation specialist for Sauci, a couples intimacy app where
--- ---
## Question Types
Every question has a `question_type` that determines how partners interact with it.
### `swipe` (default) - Yes/No/Maybe Proposal
The classic Sauci format. Partners swipe yes, no, or maybe. Matches are created when both answer positively.
- Can be **symmetric** (partner_text = NULL) or **asymmetric** (with partner_text + inverse pair)
- Example: "Give your partner a back massage"
### `who_likely` - "Who's More Likely To..."
Partners each pick who they think is more likely. Fun, lighthearted conversation starters.
- **ALWAYS symmetric** — partner_text = NULL, no inverse needed
- Format: "Who is more likely to [activity/trait]?"
- Match created when both answer
- Examples:
- "Who is more likely to cry during a movie?"
- "Who is more likely to plan a surprise date?"
- "Who is more likely to fall asleep first?"
- "Who is more likely to forget an anniversary?"
### `audio` - Voice Recording Prompt
Partners record a voice message in response to a prompt. Great for confessions, storytelling, impressions, compliments.
- **ALWAYS symmetric** — partner_text = NULL, no inverse needed
- `config`: `{"max_duration_seconds": 60}`
- Format: prompts that invite a natural spoken response
- Examples:
- "Record your best impression of your partner"
- "Describe your favourite memory together"
- "Say something you've never told your partner"
- "Sing a song that reminds you of your partner"
### `photo` - Photo Prompt
Partners take or share a photo. Great for selfies, throwbacks, creative challenges.
- **ALWAYS symmetric** — partner_text = NULL, no inverse needed
- No special config needed
- Format: prompts that invite a visual response
- Examples:
- "Share a photo from your camera roll that makes you think of your partner"
- "Take a selfie showing how you feel right now"
- "Share a screenshot of your last text about your partner"
- "Show the view from where you are right now"
### Type Distribution
For a standard 30-question pack, aim for roughly:
- **20-22 swipe** questions (the core experience)
- **4-5 who_likely** questions
- **2-3 audio** questions
- **2-3 photo** questions
Adjust based on the pack's theme. A "Getting to Know You" pack might lean heavier on who_likely and audio. An explicit pack might be mostly swipe.
---
## Question Structure ## Question Structure
Each question has: Each question has:
- `text` - What Partner A sees - `text` - What Partner A sees
- `partner_text` - What Partner B sees (can be NULL) - `partner_text` - What Partner B sees (NULL for symmetric questions and all non-swipe types)
- `intensity` - 1-5 scale - `intensity` - 1-5 scale (for backend filtering, not shown in UI)
- `question_type` - `swipe`, `who_likely`, `audio`, or `photo`
- `config` - JSONB for type-specific settings (e.g., `{"max_duration_seconds": 60}` for audio)
- `allowed_couple_genders` - Which couple types see this (NULL = all) - `allowed_couple_genders` - Which couple types see this (NULL = all)
- `target_user_genders` - Which gender initiates (NULL = any) - `target_user_genders` - Which gender initiates (NULL = any)
- `required_props` - Physical items needed (NULL = none) - `required_props` - Physical items needed (NULL = none)
- `inverse_of` - UUID of primary question if this is an inverse (NULL for primary/symmetric) - `inverse_of` - UUID of primary question if this is an inverse (NULL for primary/symmetric/non-swipe)
### When to use partner_text ### When to use partner_text (swipe questions only)
**Use NULL** for symmetric activities where both partners do the same thing: **Use NULL** for symmetric activities where both partners do the same thing:
``` ```
@@ -58,9 +118,11 @@ text: "Give your partner a massage"
partner_text: "Receive a massage from your partner" partner_text: "Receive a massage from your partner"
``` ```
### Critical: Inverse Questions and Database Linking **Non-swipe types (who_likely, audio, photo) ALWAYS have partner_text = NULL.**
For any asymmetric question, you MUST create TWO questions AND link them using the `inverse_of` column: ### Critical: Inverse Questions and Database Linking (swipe only)
For any asymmetric **swipe** question, you MUST create TWO questions AND link them using the `inverse_of` column:
**Question 1 (PRIMARY - inverse_of = NULL):** **Question 1 (PRIMARY - inverse_of = NULL):**
``` ```
@@ -86,8 +148,7 @@ This ensures:
**IMPORTANT:** **IMPORTANT:**
- Inverse pairs are NOT duplicates. When one question's `text` matches another's `partner_text`, they form a valid inverse pair. This is intentional and required - do not flag or remove these as duplicates. - Inverse pairs are NOT duplicates. When one question's `text` matches another's `partner_text`, they form a valid inverse pair. This is intentional and required - do not flag or remove these as duplicates.
- The `inverse_of` column links the pair: the inverse question points to the primary question's UUID - Non-swipe types (who_likely, audio, photo) NEVER have inverses — they are always symmetric.
- This allows the app to display accurate unique question counts (100 concepts, not 200 questions)
--- ---
@@ -106,22 +167,17 @@ Controls which couple types see the question. Options: `male+male`, `female+male
| Requires vagina | `['female+male', 'female+female']` | Exclude M+M | | Requires vagina | `['female+male', 'female+female']` | Exclude M+M |
| Sex toys | NULL | Toys work for everyone | | Sex toys | NULL | Toys work for everyone |
Examples: **Note:** who_likely, audio, and photo questions are always NULL (they're gender-neutral by nature).
- "Give your partner a massage" → NULL
- "Give your partner a blowjob" → `['male+male', 'female+male']`
- "Go down on your partner" → NULL (oral is possible for all)
- "Use a vibrator on your partner" → NULL
### Initiator Targeting (target_user_genders) ### Initiator Targeting (target_user_genders)
For asymmetric questions, controls who does the action in `text`. For asymmetric swipe questions, controls who does the action in `text`.
**DEFAULT: NULL** (anyone can initiate). **DEFAULT: NULL** (anyone can initiate).
Only set when the `text` field requires specific anatomy: Only set when the `text` field requires specific anatomy:
- "Swallow your partner's cum" → partner has penis → `['female']` in M+F - "Swallow your partner's cum" → partner has penis → `['female']` in M+F
- "Deep throat your partner" → partner has penis → `['female']` in M+F - "Deep throat your partner" → partner has penis → `['female']` in M+F
- "Let your partner cum inside you" → receiver has vagina → `['female']`
When in doubt, use NULL. When in doubt, use NULL.
@@ -154,6 +210,8 @@ Identify physical items/accessories needed for the activity.
## Intensity Levels ## Intensity Levels
Each question needs an `intensity` value (1-5) for backend filtering. This is not shown in the mobile UI but is used to gate content based on user preferences.
### Level 1 - GENTLE ### Level 1 - GENTLE
Pure emotional connection, non-sexual bonding Pure emotional connection, non-sexual bonding
- Examples: Cook together, watch movies, take walks, game nights - Examples: Cook together, watch movies, take walks, game nights
@@ -161,7 +219,7 @@ Pure emotional connection, non-sexual bonding
### Level 2 - WARM ### Level 2 - WARM
Romantic atmosphere, affectionate touch Romantic atmosphere, affectionate touch
- Examples: Slow dancing, candlelit dinners, extended kissing, cuddling - Examples: Slow dancing, extended kissing, cuddling
- Romantic but not explicit - Romantic but not explicit
### Level 3 - PLAYFUL ### Level 3 - PLAYFUL
@@ -181,44 +239,24 @@ Advanced/BDSM/extreme exploration
--- ---
## Pack Progression Structure
Most categories should have 3-5 packs that progress in intensity:
### Example: Toy Time
1. **Starting Out** (intensity 3-4) - Basic vibrators, blindfolds, simple toys
2. **Leveling Up Together** (intensity 4, some 5) - Remote toys, edging, combinations
3. **The Deep End** (intensity 4-5) - Machines, bondage furniture, advanced play
### Example: Long Distance
1. **Staying Close** (intensity 1) - FaceTime, voice notes, gaming together
2. **Missing You** (intensity 2) - Flirty texts, missing each other, anticipation
3. **Private Line** (intensity 3-4) - Sexting, phone sex, explicit photos
4. **Total Control** (intensity 4-5) - Remote toys, edging, power dynamics
### Example: Public Thrills
1. **Testing the Waters** (intensity 2-3) - Whispers, secret touches, stolen kisses
2. **Pushing Limits** (intensity 3-4) - Making out in risky spots, dressing rooms
3. **All Eyes On Us** (intensity 4-5) - Sex in public places, exhibitionism
---
## Category Types ## Category Types
### Non-Sexual Categories (intensity 1-2) ### Non-Sexual Categories (intensity 1-2)
- Social Life, Quality Time, Kitchen Chemistry - Social Life, Quality Time, Kitchen Chemistry
- Focus on bonding, activities, preferences - Focus on bonding, activities, preferences
- partner_text usually NULL (symmetric activities) - partner_text usually NULL (symmetric activities)
- Great fit for who_likely, audio, and photo questions
### Sexual Categories (intensity 3-5) ### Sexual Categories (intensity 3-5)
- Toy Time, Public Thrills, Kink Lab, Bedroom Adventures - Toy Time, Public Thrills, Kink Lab, Bedroom Adventures
- Mix of symmetric and asymmetric questions - Mix of symmetric and asymmetric swipe questions
- Always create inverse questions for asymmetric content - Always create inverse questions for asymmetric swipe content
- who_likely questions can still work here ("Who is more likely to initiate?")
### Mixed Categories ### Mixed Categories
- Long Distance, Romance & Sensuality - Long Distance, Romance & Sensuality
- Start non-sexual, progress to sexual - Start non-sexual, progress to sexual
- Clear intensity progression across packs - Good variety of all question types
--- ---
@@ -231,11 +269,16 @@ Most categories should have 3-5 packs that progress in intensity:
- Think about what 25-40 year olds actually do - Think about what 25-40 year olds actually do
- Include multiple perspectives per kink/activity - Include multiple perspectives per kink/activity
- Make activities feel achievable and fun - Make activities feel achievable and fun
- Include a mix of question types in every pack
- Make who_likely questions fun and debatable
- Make audio prompts feel natural to speak to
- Make photo prompts easy to respond to (not requiring elaborate setups)
### DON'T: ### DON'T:
- Use cheesy phrases ("make love", "intimate connection", "souls intertwining") - Use cheesy phrases ("make love", "intimate connection", "souls intertwining")
- Assume heterosexual couples by default - Assume heterosexual couples by default
- Create one-sided asymmetric questions (always make the inverse) - Create one-sided asymmetric swipe questions (always make the inverse)
- Create inverses for who_likely, audio, or photo (they're symmetric)
- Use time-specific language - Use time-specific language
- Be preachy or educational in tone - Be preachy or educational in tone
- Include judgment about activities - Include judgment about activities
@@ -257,22 +300,23 @@ When asked to create a pack:
1. **Understand the category and position** 1. **Understand the category and position**
- What category does this belong to? - What category does this belong to?
- Where in the progression is this pack? (beginner/intermediate/advanced) - What packs already exist?
- What intensity range? - Is the category explicit or non-explicit?
2. **Brainstorm themes and activities** 2. **Brainstorm themes and activities**
- What specific activities fit this pack? - What specific activities fit this pack?
- What makes this pack different from adjacent packs? - What makes this pack different from adjacent packs?
- What progression from previous pack? - What question types fit best for this theme?
3. **Generate questions** 3. **Generate questions**
- Mix of symmetric (null partner_text) and asymmetric questions - Mix of swipe, who_likely, audio, and photo types
- For EVERY asymmetric question, immediately create its inverse - For swipe: mix of symmetric (null partner_text) and asymmetric questions
- For EVERY asymmetric swipe question, immediately create its inverse
- Vary sentence structure and openers - Vary sentence structure and openers
- Target ~30-50 questions per pack - Target ~30-50 questions per pack
4. **Review for balance** 4. **Review for balance**
- Check intensity distribution - Check type distribution
- Ensure all perspectives covered - Ensure all perspectives covered
- Remove duplicates or too-similar questions - Remove duplicates or too-similar questions
- Verify no cliches or cheesy language - Verify no cliches or cheesy language
@@ -286,137 +330,125 @@ When asked to create a pack:
## Output Format ## Output Format
When generating packs, output in this format (with inverse_of linking for pairs): When generating packs, output in this format:
```sql ```sql
-- Pack: [Pack Name] -- Pack: [Pack Name]
-- Category: [Category Name] -- Category: [Category Name]
-- Description: [Pack description] -- Description: [Pack description]
-- Intensity range: [X-Y]
-- For inverse pairs, insert primary first (inverse_of = NULL), then inverse pointing to primary -- Swipe inverse pairs: insert primary first (inverse_of = NULL), then inverse pointing to primary
INSERT INTO questions (id, pack_id, text, partner_text, intensity, allowed_couple_genders, target_user_genders, required_props, inverse_of) VALUES INSERT INTO questions (id, pack_id, text, partner_text, intensity, question_type, config, allowed_couple_genders, target_user_genders, required_props, inverse_of) VALUES
-- Pair 1 -- Swipe pair 1
('uuid-primary-1', '[pack_id]', 'Spank your partner', 'Be spanked by your partner', 3, NULL, NULL, NULL, NULL), ('uuid-primary-1', '[pack_id]', 'Spank your partner', 'Be spanked by your partner', 3, 'swipe', '{}', NULL, NULL, NULL, NULL),
('uuid-inverse-1', '[pack_id]', 'Be spanked by your partner', 'Spank your partner', 3, NULL, NULL, NULL, 'uuid-primary-1'), ('uuid-inverse-1', '[pack_id]', 'Be spanked by your partner', 'Spank your partner', 3, 'swipe', '{}', NULL, NULL, NULL, 'uuid-primary-1'),
-- Pair 2 (with targeting) -- Symmetric swipe (no inverse)
('uuid-primary-2', '[pack_id]', 'Give your partner a blowjob', 'Receive a blowjob from your partner', 3, ARRAY['male+male', 'female+male'], NULL, NULL, NULL), (gen_random_uuid(), '[pack_id]', 'Cook dinner together', NULL, 1, 'swipe', '{}', NULL, NULL, NULL, NULL),
('uuid-inverse-2', '[pack_id]', 'Receive a blowjob from your partner', 'Give your partner a blowjob', 3, ARRAY['male+male', 'female+male'], NULL, NULL, 'uuid-primary-2'), -- who_likely (always symmetric, no inverse)
-- Symmetric (no inverse needed) (gen_random_uuid(), '[pack_id]', 'Who is more likely to burn dinner?', NULL, 1, 'who_likely', '{}', NULL, NULL, NULL, NULL),
(gen_random_uuid(), '[pack_id]', 'Cook dinner together', NULL, 1, NULL, NULL, NULL, NULL); -- audio (always symmetric, no inverse)
(gen_random_uuid(), '[pack_id]', 'Describe your perfect date in 30 seconds', NULL, 1, 'audio', '{"max_duration_seconds": 60}', NULL, NULL, NULL, NULL),
-- photo (always symmetric, no inverse)
(gen_random_uuid(), '[pack_id]', 'Share a photo that makes you smile', NULL, 1, 'photo', '{}', NULL, NULL, NULL, NULL);
``` ```
Or as a table (include inverse_of column):
| ID | Text | Partner Text | Intensity | Couple Targets | Initiator | Props | inverse_of |
|----|------|--------------|-----------|----------------|-----------|-------|------------|
| uuid-1 | Spank your partner | Be spanked | 3 | NULL | NULL | NULL | NULL |
| uuid-2 | Be spanked by partner | Spank your partner | 3 | NULL | NULL | NULL | uuid-1 |
| uuid-3 | Cook together | null | 1 | NULL | NULL | NULL | NULL |
--- ---
## Examples of Good Questions ## Examples of Good Questions
### Non-Sexual (Intensity 1) ### Swipe - Non-Sexual (Intensity 1)
``` ```
text: "Cook the same recipe together over video call" text: "Cook the same recipe together over video call"
partner_text: NULL partner_text: NULL
intensity: 1 intensity: 1
allowed_couple_genders: NULL question_type: swipe
target_user_genders: NULL config: {}
required_props: NULL
``` ```
### Romantic (Intensity 2) ### Swipe - Romantic (Intensity 2)
``` ```
text: "Send a tipsy voice note telling them you miss them" text: "Send a tipsy voice note telling them you miss them"
partner_text: NULL partner_text: NULL
intensity: 2 intensity: 2
allowed_couple_genders: NULL question_type: swipe
target_user_genders: NULL config: {}
required_props: NULL
``` ```
### Playful (Intensity 3) - With Props and Inverse Linking ### Swipe - Playful (Intensity 3) - With Props and Inverse
``` ```
id: uuid-blindfold-primary id: uuid-blindfold-primary
text: "Blindfold your partner and tease them with different sensations" text: "Blindfold your partner and tease them with different sensations"
partner_text: "Be blindfolded while your partner teases you" partner_text: "Be blindfolded while your partner teases you"
intensity: 3 intensity: 3
allowed_couple_genders: NULL question_type: swipe
target_user_genders: NULL config: {}
required_props: ["blindfold"] required_props: ["blindfold"]
inverse_of: NULL inverse_of: NULL
``` ```
AND its inverse (linked via inverse_of): AND its inverse:
``` ```
id: uuid-blindfold-inverse id: uuid-blindfold-inverse
text: "Be blindfolded while your partner teases you" text: "Be blindfolded while your partner teases you"
partner_text: "Blindfold your partner and tease them with different sensations" partner_text: "Blindfold your partner and tease them with different sensations"
intensity: 3 intensity: 3
allowed_couple_genders: NULL question_type: swipe
target_user_genders: NULL config: {}
required_props: ["blindfold"] required_props: ["blindfold"]
inverse_of: uuid-blindfold-primary inverse_of: uuid-blindfold-primary
``` ```
### Steamy (Intensity 4) - With Props and Targeting ### who_likely
``` ```
text: "Control your partner's remote vibrator while out at dinner" text: "Who is more likely to forget an anniversary?"
partner_text: "Wear a remote vibrator at dinner while your partner controls it" partner_text: NULL
intensity: 4 intensity: 1
allowed_couple_genders: NULL question_type: who_likely
target_user_genders: NULL config: {}
required_props: ["remote vibrator"]
```
AND its inverse:
```
text: "Wear a remote vibrator at dinner while your partner controls it"
partner_text: "Control your partner's remote vibrator while out at dinner"
intensity: 4
allowed_couple_genders: NULL
target_user_genders: NULL
required_props: ["remote vibrator"]
``` ```
### With Couple Targeting (Intensity 3) ### audio
```
text: "Describe the moment you knew you loved your partner"
partner_text: NULL
intensity: 2
question_type: audio
config: {"max_duration_seconds": 60}
```
### photo
```
text: "Share the oldest photo you have of the two of you"
partner_text: NULL
intensity: 1
question_type: photo
config: {}
```
### Swipe - Steamy (Intensity 4) - With Couple Targeting
``` ```
text: "Give your partner a blowjob in a risky location" text: "Give your partner a blowjob in a risky location"
partner_text: "Receive a blowjob from your partner in a risky location" partner_text: "Receive a blowjob from your partner in a risky location"
intensity: 3 intensity: 3
allowed_couple_genders: ["male+male", "female+male"] -- requires penis question_type: swipe
target_user_genders: NULL config: {}
required_props: NULL allowed_couple_genders: ["male+male", "female+male"]
``` ```
### With Initiator Targeting (Intensity 4) ### Swipe - Intense (Intensity 5)
```
text: "Swallow your partner's cum"
partner_text: "Have your partner swallow your cum"
intensity: 4
allowed_couple_genders: ["male+male", "female+male"] -- requires penis
target_user_genders: ["female"] -- in M+F, female receives
required_props: NULL
```
### Intense (Intensity 5)
``` ```
text: "Edge your partner repeatedly, denying release until you decide" text: "Edge your partner repeatedly, denying release until you decide"
partner_text: "Be edged and denied until your partner allows you to finish" partner_text: "Be edged and denied until your partner allows you to finish"
intensity: 5 intensity: 5
allowed_couple_genders: NULL question_type: swipe
target_user_genders: NULL config: {}
required_props: NULL
``` ```
AND its inverse: AND its inverse:
``` ```
text: "Be edged and denied until your partner allows you to finish" text: "Be edged and denied until your partner allows you to finish"
partner_text: "Edge your partner repeatedly, denying release until you decide" partner_text: "Edge your partner repeatedly, denying release until you decide"
intensity: 5 intensity: 5
allowed_couple_genders: NULL question_type: swipe
target_user_genders: NULL config: {}
required_props: NULL
``` ```
--- ---
@@ -425,7 +457,7 @@ required_props: NULL
**Language & Format:** **Language & Format:**
- [ ] Uses "your partner" (no gendered pronouns like him/her/he/she) - [ ] Uses "your partner" (no gendered pronouns like him/her/he/she)
- [ ] Questions are proposals, not interview questions - [ ] Swipe questions are proposals, not interview questions
- [ ] No wishy-washy language ("Would you...", "Have you ever...") - [ ] No wishy-washy language ("Would you...", "Have you ever...")
- [ ] No time-specific language (tonight, now, today) - [ ] No time-specific language (tonight, now, today)
- [ ] 5-12 words per question (concise) - [ ] 5-12 words per question (concise)
@@ -433,26 +465,35 @@ required_props: NULL
- [ ] Mix of sentence structures - [ ] Mix of sentence structures
- [ ] Appropriate for 25-40 year old couples - [ ] Appropriate for 25-40 year old couples
**Question Types:**
- [ ] Every question has a `question_type` set
- [ ] who_likely uses "Who is more likely to..." format
- [ ] audio prompts invite a natural spoken response
- [ ] photo prompts invite a visual response
- [ ] Non-swipe types have partner_text = NULL and inverse_of = NULL
- [ ] Type distribution is balanced
**Structure:** **Structure:**
- [ ] All asymmetric questions have their inverse created (these are NOT duplicates - they're required pairs) - [ ] All asymmetric swipe questions have their inverse created
- [ ] All inverse pairs are linked via `inverse_of` column (inverse points to primary's UUID) - [ ] All inverse pairs are linked via `inverse_of` column
- [ ] Partner text is appealing (not clinical) - [ ] Partner text is appealing (not clinical)
- [ ] Partner text frames responses as "allowing" not forced - [ ] Partner text frames responses as "allowing" not forced
- [ ] No actual duplicates (same `text` appearing twice) - [ ] No actual duplicates (same `text` appearing twice)
**Database Linking:** **Database Linking:**
- [ ] Primary questions have `inverse_of = NULL` - [ ] Primary swipe questions have `inverse_of = NULL`
- [ ] Inverse questions have `inverse_of = <primary_question_uuid>` - [ ] Inverse swipe questions have `inverse_of = <primary_question_uuid>`
- [ ] Symmetric questions have `inverse_of = NULL` - [ ] Symmetric swipe questions have `inverse_of = NULL`
- [ ] Non-swipe questions have `inverse_of = NULL`
**Accuracy:** **Accuracy:**
- [ ] Intensity levels match the activities - [ ] Intensity levels match the activities
- [ ] Couple targeting is correct (NULL unless anatomy-specific) - [ ] Couple targeting is correct (NULL unless anatomy-specific)
- [ ] Initiator targeting is correct for asymmetric questions - [ ] Initiator targeting is correct for asymmetric swipe questions
- [ ] Required props are identified where needed - [ ] Required props are identified where needed
- [ ] Consider same-sex couples - [ ] Consider same-sex couples
**Consistency:** **Consistency:**
- [ ] No mixed anatomy in alternatives - [ ] No mixed anatomy in alternatives
- [ ] Tone matches explicit/non-explicit category - [ ] Tone matches explicit/non-explicit category
- [ ] Pack fits its position in the progression - [ ] Pack fits its position in the category
+203 -2
View File
@@ -37,7 +37,7 @@ import {
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} from '@/components/ui/select'; } from '@/components/ui/select';
import { Crown, Users, MessageCircle, ChevronRight, ThumbsUp, ThumbsDown, Minus, Gift, Image, Video as VideoIcon, Target, User, Eye, EyeOff, CheckCircle, Package, Sparkles, Flame, Trophy, Calendar } from 'lucide-react'; import { Crown, Users, MessageCircle, ChevronRight, ThumbsUp, ThumbsDown, Minus, Gift, Image, Video as VideoIcon, Target, User, Eye, EyeOff, CheckCircle, Package, Sparkles, Flame, Trophy, Calendar, AlertCircle, Clock } from 'lucide-react';
import { format } from 'date-fns'; import { format } from 'date-fns';
import { IconPreview } from '@/components/ui/icon-picker'; import { IconPreview } from '@/components/ui/icon-picker';
@@ -130,6 +130,20 @@ interface FeatureInterest {
created_at: string; created_at: string;
} }
interface OutstandingQuestion {
id: string;
text: string;
intensity: number | null;
pack: {
id: string;
name: string;
};
partner_response: {
answer: 'yes' | 'no' | 'maybe';
created_at: string | null;
};
}
interface CoupleStreak { interface CoupleStreak {
id: string; id: string;
couple_id: string; couple_id: string;
@@ -277,6 +291,13 @@ export function UserDetailPage() {
const [enabledPacks, setEnabledPacks] = useState<EnabledPack[]>([]); const [enabledPacks, setEnabledPacks] = useState<EnabledPack[]>([]);
const [featureInterests, setFeatureInterests] = useState<FeatureInterest[]>([]); const [featureInterests, setFeatureInterests] = useState<FeatureInterest[]>([]);
const [streak, setStreak] = useState<CoupleStreak | null>(null); const [streak, setStreak] = useState<CoupleStreak | null>(null);
const [outstandingQuestions, setOutstandingQuestions] = useState<OutstandingQuestion[]>([]);
const [outstandingPage, setOutstandingPage] = useState(1);
const [outstandingPageSize, setOutstandingPageSize] = useState(10);
const [outstandingTotal, setOutstandingTotal] = useState(0);
const [gapDisabled, setGapDisabled] = useState(false);
const [gapThreshold, setGapThreshold] = useState(0);
const [netGap, setNetGap] = useState(0);
const [responsesPage, setResponsesPage] = useState(1); const [responsesPage, setResponsesPage] = useState(1);
const [responsesPageSize, setResponsesPageSize] = useState(10); const [responsesPageSize, setResponsesPageSize] = useState(10);
const [responsesTotal, setResponsesTotal] = useState(0); const [responsesTotal, setResponsesTotal] = useState(0);
@@ -467,19 +488,115 @@ export function UserDetailPage() {
} else { } else {
setEnabledPacks((enabledPacksData || []) as unknown as EnabledPack[]); setEnabledPacks((enabledPacksData || []) as unknown as EnabledPack[]);
} }
// Fetch outstanding questions (partner answered, user hasn't)
// Use partner from state (set earlier in the first couple_id block)
const partnerProfile = partner || (await supabase.from('profiles').select('id, name').eq('couple_id', profileData.couple_id).neq('id', userId).maybeSingle()).data;
if (partnerProfile) {
// Get question IDs user has already answered
const { data: userResponseIds } = await supabase
.from('responses')
.select('question_id')
.eq('user_id', userId);
const answeredIds = new Set((userResponseIds || []).map((r: any) => r.question_id));
// Get all partner responses in this couple
const { data: partnerResponses } = await supabase
.from('responses')
.select(`
question_id,
answer,
created_at
`)
.eq('user_id', partnerProfile.id)
.eq('couple_id', profileData.couple_id)
.order('created_at', { ascending: false });
// Filter to only questions user hasn't answered
const unansweredPartnerResponses = (partnerResponses || []).filter((r: any) => !answeredIds.has(r.question_id));
const unansweredQuestionIds = unansweredPartnerResponses.map((r: any) => r.question_id);
if (unansweredQuestionIds.length > 0) {
// Fetch question details (no pack filter — admin sees all)
const { data: questionDetails } = await supabase
.from('questions')
.select('id, text, intensity, pack:question_packs(id, name)')
.in('id', unansweredQuestionIds);
const questionMap = new Map((questionDetails || []).map((q: any) => [q.id, q]));
const partnerResponseMap = new Map(unansweredPartnerResponses.map((r: any) => [r.question_id, r]));
const outstanding: OutstandingQuestion[] = [];
for (const qId of unansweredQuestionIds) {
const q = questionMap.get(qId);
const pr = partnerResponseMap.get(qId);
if (q && pr) {
outstanding.push({
id: qId,
text: q.text || '',
intensity: q.intensity || null,
pack: { id: q.pack?.id || '', name: q.pack?.name || '' },
partner_response: { answer: pr.answer, created_at: pr.created_at },
});
}
}
setOutstandingTotal(outstanding.length);
const outFrom = (outstandingPage - 1) * outstandingPageSize;
setOutstandingQuestions(outstanding.slice(outFrom, outFrom + outstandingPageSize));
} else {
setOutstandingQuestions([]);
setOutstandingTotal(0);
}
// Calculate gap status
const { data: appConfig } = await supabase
.from('app_config')
.select('answer_gap_threshold')
.limit(1)
.maybeSingle();
const threshold = appConfig?.answer_gap_threshold ?? 10;
setGapThreshold(threshold);
if (threshold > 0) {
// Count questions user answered that partner hasn't
const partnerAllAnswered = new Set((partnerResponses || []).map((r: any) => r.question_id));
const { data: userResponses } = await supabase
.from('responses')
.select('question_id')
.eq('user_id', userId)
.eq('couple_id', profileData.couple_id);
const userAhead = (userResponses || []).filter((r: any) => !partnerAllAnswered.has(r.question_id)).length;
const partnerAhead = unansweredPartnerResponses.length;
const gap = Math.max(0, userAhead - partnerAhead);
setNetGap(gap);
setGapDisabled(gap >= threshold);
} else {
setNetGap(0);
setGapDisabled(false);
}
} else {
setOutstandingQuestions([]);
setOutstandingTotal(0);
setGapDisabled(false);
}
} else { } else {
setMatches([]); setMatches([]);
setMatchesTotal(0); setMatchesTotal(0);
setMediaMessages([]); setMediaMessages([]);
setMediaTotal(0); setMediaTotal(0);
setEnabledPacks([]); setEnabledPacks([]);
setOutstandingQuestions([]);
setOutstandingTotal(0);
setGapDisabled(false);
} }
} catch (error) { } catch (error) {
console.error('Failed to load user data:', error); console.error('Failed to load user data:', error);
} finally { } finally {
setLoading(false); setLoading(false);
} }
}, [userId, responsesPage, responsesPageSize, matchesPage, matchesPageSize, mediaPage, mediaPageSize]); }, [userId, responsesPage, responsesPageSize, matchesPage, matchesPageSize, mediaPage, mediaPageSize, outstandingPage, outstandingPageSize]);
const { status: profileStatus } = useRealtimeSubscription<Profile>({ table: 'profiles', filter: userId ? `id=eq.${userId}` : undefined, enabled: !!userId, onUpdate: useCallback(({ new: updated }: { old: Profile; new: Profile }) => { setProfile(updated); }, []) }); const { status: profileStatus } = useRealtimeSubscription<Profile>({ table: 'profiles', filter: userId ? `id=eq.${userId}` : undefined, enabled: !!userId, onUpdate: useCallback(({ new: updated }: { old: Profile; new: Profile }) => { setProfile(updated); }, []) });
const { status: responsesStatus } = useRealtimeSubscription<Response>({ table: 'responses', filter: userId ? `user_id=eq.${userId}` : undefined, enabled: !!userId, onInsert: fetchData, onDelete: fetchData }); const { status: responsesStatus } = useRealtimeSubscription<Response>({ table: 'responses', filter: userId ? `user_id=eq.${userId}` : undefined, enabled: !!userId, onInsert: fetchData, onDelete: fetchData });
@@ -490,6 +607,7 @@ export function UserDetailPage() {
setResponsesPage(1); setResponsesPage(1);
setMatchesPage(1); setMatchesPage(1);
setMediaPage(1); setMediaPage(1);
setOutstandingPage(1);
}, [userId]); }, [userId]);
useEffect(() => { useEffect(() => {
@@ -526,6 +644,8 @@ export function UserDetailPage() {
<h1 className="text-2xl font-bold">{profile.name || 'Unnamed User'}</h1> <h1 className="text-2xl font-bold">{profile.name || 'Unnamed User'}</h1>
<RealtimeStatusIndicator status={profileStatus === 'SUBSCRIBED' && responsesStatus === 'SUBSCRIBED' ? 'SUBSCRIBED' : profileStatus} showLabel /> <RealtimeStatusIndicator status={profileStatus === 'SUBSCRIBED' && responsesStatus === 'SUBSCRIBED' ? 'SUBSCRIBED' : profileStatus} showLabel />
{profile.is_premium && <Badge className="bg-amber-500"><Crown className="h-3 w-3 mr-1" />Premium</Badge>} {profile.is_premium && <Badge className="bg-amber-500"><Crown className="h-3 w-3 mr-1" />Premium</Badge>}
{outstandingTotal > 0 && <Badge variant="outline" className="gap-1"><Clock className="h-3 w-3" />{outstandingTotal} outstanding</Badge>}
{gapDisabled && <Badge variant="destructive" className="gap-1"><AlertCircle className="h-3 w-3" />Gap blocked ({netGap}/{gapThreshold})</Badge>}
{!profile.is_premium && hasPermission(PERMISSION_KEYS.GIFT_PREMIUM) && <Dialog open={upgradeOpen} onOpenChange={setUpgradeOpen}><DialogTrigger asChild><Button size="sm" variant="outline" className="ml-2 gap-1 h-7"><Gift className="h-3.5 w-3.5" />Gift Premium</Button></DialogTrigger><DialogContent><DialogHeader><DialogTitle>Gift Premium Access</DialogTitle><DialogDescription>Manually upgrade this user to premium status.</DialogDescription></DialogHeader><div className="grid gap-4 py-4"><div className="grid gap-2"><Label htmlFor="expiry-type">Duration</Label><Select value={expiryType} onValueChange={setExpiryType}><SelectTrigger id="expiry-type"><SelectValue placeholder="Select duration" /></SelectTrigger><SelectContent><SelectItem value="forever">Lifetime (No Expiry)</SelectItem><SelectItem value="1_month">1 Month</SelectItem><SelectItem value="1_year">1 Year</SelectItem><SelectItem value="custom">Custom Date</SelectItem></SelectContent></Select></div>{expiryType === 'custom' && (<div className="grid gap-2"><Label htmlFor="custom-date">Expiry Date</Label><Input id="custom-date" type="datetime-local" value={customDate} onChange={(e) => setCustomDate(e.target.value)} /></div>)}</div><DialogFooter><Button variant="outline" onClick={() => setUpgradeOpen(false)}>Cancel</Button><Button onClick={handleUpgrade} disabled={upgrading}>{upgrading ? 'Upgrading...' : 'Confirm Upgrade'}</Button></DialogFooter></DialogContent></Dialog>} {!profile.is_premium && hasPermission(PERMISSION_KEYS.GIFT_PREMIUM) && <Dialog open={upgradeOpen} onOpenChange={setUpgradeOpen}><DialogTrigger asChild><Button size="sm" variant="outline" className="ml-2 gap-1 h-7"><Gift className="h-3.5 w-3.5" />Gift Premium</Button></DialogTrigger><DialogContent><DialogHeader><DialogTitle>Gift Premium Access</DialogTitle><DialogDescription>Manually upgrade this user to premium status.</DialogDescription></DialogHeader><div className="grid gap-4 py-4"><div className="grid gap-2"><Label htmlFor="expiry-type">Duration</Label><Select value={expiryType} onValueChange={setExpiryType}><SelectTrigger id="expiry-type"><SelectValue placeholder="Select duration" /></SelectTrigger><SelectContent><SelectItem value="forever">Lifetime (No Expiry)</SelectItem><SelectItem value="1_month">1 Month</SelectItem><SelectItem value="1_year">1 Year</SelectItem><SelectItem value="custom">Custom Date</SelectItem></SelectContent></Select></div>{expiryType === 'custom' && (<div className="grid gap-2"><Label htmlFor="custom-date">Expiry Date</Label><Input id="custom-date" type="datetime-local" value={customDate} onChange={(e) => setCustomDate(e.target.value)} /></div>)}</div><DialogFooter><Button variant="outline" onClick={() => setUpgradeOpen(false)}>Cancel</Button><Button onClick={handleUpgrade} disabled={upgrading}>{upgrading ? 'Upgrading...' : 'Confirm Upgrade'}</Button></DialogFooter></DialogContent></Dialog>}
</div> </div>
<div className="text-muted-foreground mb-4 flex items-center gap-2"> <div className="text-muted-foreground mb-4 flex items-center gap-2">
@@ -633,6 +753,7 @@ export function UserDetailPage() {
{canViewResponses && <TabsTrigger value="responses">Responses ({responsesTotal})</TabsTrigger>} {canViewResponses && <TabsTrigger value="responses">Responses ({responsesTotal})</TabsTrigger>}
{canViewMatches && <TabsTrigger value="matches" disabled={!profile.couple_id}>Matches ({matchesTotal})</TabsTrigger>} {canViewMatches && <TabsTrigger value="matches" disabled={!profile.couple_id}>Matches ({matchesTotal})</TabsTrigger>}
{canViewMedia && <TabsTrigger value="media">Media ({mediaTotal})</TabsTrigger>} {canViewMedia && <TabsTrigger value="media">Media ({mediaTotal})</TabsTrigger>}
{canViewResponses && <TabsTrigger value="outstanding" disabled={!profile.couple_id || !partner}>Outstanding ({outstandingTotal}){gapDisabled && <AlertCircle className="h-3 w-3 ml-1 text-destructive" />}</TabsTrigger>}
<TabsTrigger value="packs" disabled={!profile.couple_id}>Enabled Packs ({enabledPacks.length})</TabsTrigger> <TabsTrigger value="packs" disabled={!profile.couple_id}>Enabled Packs ({enabledPacks.length})</TabsTrigger>
</TabsList> </TabsList>
{canViewResponses && <TabsContent value="responses" className="mt-4">{responses.length === 0 ? <Card className="flex flex-col items-center justify-center py-12"><p className="text-muted-foreground">No responses yet</p></Card> : <div className="space-y-4"><div className="rounded-md border"><Table><TableHeader><TableRow><TableHead>Question</TableHead><TableHead>Pack</TableHead><TableHead className="w-24">Answer</TableHead><TableHead className="w-32">Date</TableHead></TableRow></TableHeader><TableBody>{responses.map((r) => (<TableRow key={r.id}><TableCell className="max-w-md">{r.question.pack?.id ? (<Link to={`/packs/${r.question.pack.id}/questions`} className="line-clamp-2 text-primary hover:underline cursor-pointer">{r.question.text}</Link>) : (<span className="line-clamp-2">{r.question.text}</span>)}</TableCell><TableCell>{r.question.pack?.id ? (<Link to={`/packs/${r.question.pack.id}/questions`} className="text-primary hover:underline">{r.question.pack.name}</Link>) : (<span className="text-muted-foreground">{r.question.pack?.name || '—'}</span>)}</TableCell><TableCell><div className="flex items-center gap-2">{answerIcons[r.answer]}<span className="capitalize">{r.answer}</span></div></TableCell><TableCell className="text-muted-foreground text-sm">{r.created_at ? format(new Date(r.created_at), 'MMM d, yyyy') : '—'}</TableCell></TableRow>))}</TableBody></Table></div><PaginationControls page={responsesPage} pageSize={responsesPageSize} totalCount={responsesTotal} onPageChange={setResponsesPage} onPageSizeChange={(size) => { setResponsesPage(1); setResponsesPageSize(size); }} /></div>}</TabsContent>} {canViewResponses && <TabsContent value="responses" className="mt-4">{responses.length === 0 ? <Card className="flex flex-col items-center justify-center py-12"><p className="text-muted-foreground">No responses yet</p></Card> : <div className="space-y-4"><div className="rounded-md border"><Table><TableHeader><TableRow><TableHead>Question</TableHead><TableHead>Pack</TableHead><TableHead className="w-24">Answer</TableHead><TableHead className="w-32">Date</TableHead></TableRow></TableHeader><TableBody>{responses.map((r) => (<TableRow key={r.id}><TableCell className="max-w-md">{r.question.pack?.id ? (<Link to={`/packs/${r.question.pack.id}/questions`} className="line-clamp-2 text-primary hover:underline cursor-pointer">{r.question.text}</Link>) : (<span className="line-clamp-2">{r.question.text}</span>)}</TableCell><TableCell>{r.question.pack?.id ? (<Link to={`/packs/${r.question.pack.id}/questions`} className="text-primary hover:underline">{r.question.pack.name}</Link>) : (<span className="text-muted-foreground">{r.question.pack?.name || '—'}</span>)}</TableCell><TableCell><div className="flex items-center gap-2">{answerIcons[r.answer]}<span className="capitalize">{r.answer}</span></div></TableCell><TableCell className="text-muted-foreground text-sm">{r.created_at ? format(new Date(r.created_at), 'MMM d, yyyy') : '—'}</TableCell></TableRow>))}</TableBody></Table></div><PaginationControls page={responsesPage} pageSize={responsesPageSize} totalCount={responsesTotal} onPageChange={setResponsesPage} onPageSizeChange={(size) => { setResponsesPage(1); setResponsesPageSize(size); }} /></div>}</TabsContent>}
@@ -644,6 +765,86 @@ export function UserDetailPage() {
<AdminDecryptedImage messageId={message.id} alt={`Media from ${message.created_at}`} /> <AdminDecryptedImage messageId={message.id} alt={`Media from ${message.created_at}`} />
)} )}
</div><CardContent className="p-3"><div className="flex items-center justify-between text-sm"><div className="truncate text-muted-foreground">{message.media_type === 'video' ? 'Video' : 'Image'}</div>{canViewChats && message.match_id && <Link to={`/users/${userId}/matches/${message.match_id}`} className="text-primary hover:underline text-xs">View Chat</Link>}</div><div className="text-xs text-muted-foreground mt-1">{message.created_at ? format(new Date(message.created_at), 'MMM d, yyyy') : '—'}</div></CardContent></Card>))}</div><PaginationControls page={mediaPage} pageSize={mediaPageSize} totalCount={mediaTotal} onPageChange={setMediaPage} onPageSizeChange={(size) => { setMediaPage(1); setMediaPageSize(size); }} /></div>}</TabsContent>} </div><CardContent className="p-3"><div className="flex items-center justify-between text-sm"><div className="truncate text-muted-foreground">{message.media_type === 'video' ? 'Video' : 'Image'}</div>{canViewChats && message.match_id && <Link to={`/users/${userId}/matches/${message.match_id}`} className="text-primary hover:underline text-xs">View Chat</Link>}</div><div className="text-xs text-muted-foreground mt-1">{message.created_at ? format(new Date(message.created_at), 'MMM d, yyyy') : '—'}</div></CardContent></Card>))}</div><PaginationControls page={mediaPage} pageSize={mediaPageSize} totalCount={mediaTotal} onPageChange={setMediaPage} onPageSizeChange={(size) => { setMediaPage(1); setMediaPageSize(size); }} /></div>}</TabsContent>}
{canViewResponses && <TabsContent value="outstanding" className="mt-4">
{gapDisabled && (
<Card className="mb-4 border-destructive/50 bg-destructive/5">
<CardContent className="pt-4 pb-4">
<div className="flex items-center gap-2 text-destructive">
<AlertCircle className="h-4 w-4" />
<span className="font-medium">Gap disabled</span>
<span className="text-muted-foreground text-sm">- This user is {netGap} questions ahead of their partner (threshold: {gapThreshold}). They won't see new questions until their partner catches up.</span>
</div>
</CardContent>
</Card>
)}
{outstandingQuestions.length === 0 ? (
<Card className="flex flex-col items-center justify-center py-12">
<CheckCircle className="h-12 w-12 text-green-500 mb-4" />
<p className="text-muted-foreground">No outstanding questions - all caught up!</p>
</Card>
) : (
<div className="space-y-4">
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead>Question</TableHead>
<TableHead>Pack</TableHead>
<TableHead className="w-24">Intensity</TableHead>
<TableHead className="w-32">Partner's Answer</TableHead>
<TableHead className="w-36">Partner Answered</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{outstandingQuestions.map((q) => (
<TableRow key={q.id}>
<TableCell className="max-w-md">
{q.pack?.id ? (
<Link to={`/packs/${q.pack.id}/questions`} className="line-clamp-2 text-primary hover:underline cursor-pointer">{q.text}</Link>
) : (
<span className="line-clamp-2">{q.text}</span>
)}
</TableCell>
<TableCell>
{q.pack?.id ? (
<Link to={`/packs/${q.pack.id}/questions`} className="text-primary hover:underline">{q.pack.name}</Link>
) : (
<span className="text-muted-foreground">—</span>
)}
</TableCell>
<TableCell>
{q.intensity ? (
<div className="flex items-center gap-1">
{Array.from({ length: q.intensity }, (_, i) => (
<Flame key={i} className="h-3 w-3 text-orange-500" />
))}
</div>
) : '—'}
</TableCell>
<TableCell>
<div className="flex items-center gap-2">
{answerIcons[q.partner_response.answer]}
<span className="capitalize">{q.partner_response.answer}</span>
</div>
</TableCell>
<TableCell className="text-muted-foreground text-sm">
{q.partner_response.created_at ? format(new Date(q.partner_response.created_at), 'MMM d, yyyy') : '—'}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
<PaginationControls
page={outstandingPage}
pageSize={outstandingPageSize}
totalCount={outstandingTotal}
onPageChange={setOutstandingPage}
onPageSizeChange={(size) => { setOutstandingPage(1); setOutstandingPageSize(size); }}
/>
</div>
)}
</TabsContent>}
<TabsContent value="packs" className="mt-4"> <TabsContent value="packs" className="mt-4">
{enabledPacks.length === 0 ? ( {enabledPacks.length === 0 ? (
<Card className="flex flex-col items-center justify-center py-12"> <Card className="flex flex-col items-center justify-center py-12">
+80 -6
View File
@@ -7,6 +7,7 @@ import {
Alert, Alert,
ActivityIndicator, ActivityIndicator,
Share, Share,
Linking,
KeyboardAvoidingView, KeyboardAvoidingView,
Platform, Platform,
TouchableOpacity, TouchableOpacity,
@@ -141,12 +142,14 @@ export default function PairingScreen() {
} }
}; };
const shareMessage = couple?.invite_code
? `Join me on Sauci! Download the app at https://sauci.app and use my invite code to pair up: ${couple.invite_code}`
: "";
const shareCode = async () => { const shareCode = async () => {
if (couple?.invite_code) { if (couple?.invite_code) {
try { try {
await Share.share({ await Share.share({ message: shareMessage });
message: `Join me on Sauci! Download the app at https://sauci.app and use my invite code to pair up: ${couple.invite_code}`,
});
Events.codeShared(); Events.codeShared();
} catch (error) { } catch (error) {
console.error(error); console.error(error);
@@ -154,6 +157,23 @@ export default function PairingScreen() {
} }
}; };
const shareViaWhatsApp = async () => {
const url = `whatsapp://send?text=${encodeURIComponent(shareMessage)}`;
const canOpen = await Linking.canOpenURL(url);
if (canOpen) {
await Linking.openURL(url);
Events.codeShared();
} else {
shareCode();
}
};
const shareViaSMS = async () => {
const url = `sms:&body=${encodeURIComponent(shareMessage)}`;
await Linking.openURL(url);
Events.codeShared();
};
const handleCancelPairing = async () => { const handleCancelPairing = async () => {
Alert.alert( Alert.alert(
"Cancel Pairing", "Cancel Pairing",
@@ -256,7 +276,15 @@ export default function PairingScreen() {
</GlassCard> </GlassCard>
</Animated.View> </Animated.View>
{/* Share Button */} {/* CTA Text */}
<Animated.View
entering={FadeInDown.delay(350).duration(500)}
style={styles.ctaSection}
>
<Text style={styles.ctaText}>Send this to your partner to get started</Text>
</Animated.View>
{/* Primary Share Button */}
<Animated.View <Animated.View
entering={FadeInDown.delay(400).duration(500)} entering={FadeInDown.delay(400).duration(500)}
style={styles.section} style={styles.section}
@@ -264,12 +292,28 @@ export default function PairingScreen() {
<GlassButton <GlassButton
onPress={shareCode} onPress={shareCode}
fullWidth fullWidth
icon={<Ionicons name="share-outline" size={20} color={colors.text} />} size="lg"
icon={<Ionicons name="share-outline" size={22} color={colors.text} />}
> >
Share Code Share Invite Code
</GlassButton> </GlassButton>
</Animated.View> </Animated.View>
{/* Quick share buttons */}
<Animated.View
entering={FadeInDown.delay(450).duration(500)}
style={styles.quickShareRow}
>
<TouchableOpacity style={styles.quickShareButton} onPress={shareViaSMS} activeOpacity={0.7}>
<Ionicons name="chatbubble-outline" size={20} color={colors.text} />
<Text style={styles.quickShareLabel}>Messages</Text>
</TouchableOpacity>
<TouchableOpacity style={styles.quickShareButton} onPress={shareViaWhatsApp} activeOpacity={0.7}>
<Ionicons name="logo-whatsapp" size={20} color={colors.text} />
<Text style={styles.quickShareLabel}>WhatsApp</Text>
</TouchableOpacity>
</Animated.View>
{/* Waiting indicator */} {/* Waiting indicator */}
<Animated.View <Animated.View
entering={FadeInDown.delay(500).duration(500)} entering={FadeInDown.delay(500).duration(500)}
@@ -559,4 +603,34 @@ const styles = StyleSheet.create({
...typography.subhead, ...typography.subhead,
color: colors.error, color: colors.error,
}, },
ctaSection: {
alignItems: "center",
marginBottom: spacing.md,
},
ctaText: {
...typography.callout,
color: colors.textSecondary,
textAlign: "center",
},
quickShareRow: {
flexDirection: "row",
justifyContent: "center",
gap: spacing.md,
marginBottom: spacing.md,
},
quickShareButton: {
flexDirection: "row",
alignItems: "center",
gap: spacing.sm,
backgroundColor: colors.backgroundLight,
paddingHorizontal: spacing.md,
paddingVertical: spacing.sm,
borderRadius: radius.full,
borderWidth: 1,
borderColor: colors.border,
},
quickShareLabel: {
...typography.subhead,
color: colors.text,
},
}); });
@@ -1,8 +1,7 @@
import React, { useEffect, useMemo } from 'react'; import React, { useEffect, useMemo } from 'react';
import { View, StyleSheet, Text } from 'react-native'; import { View, StyleSheet, Text } from 'react-native';
import { colors, spacing, typography } from '../../../theme'; import { colors, spacing, typography } from '../../../theme';
import { useNotificationPreferencesStore } from '../../../store'; import { useNotificationPreferencesStore, useAuthStore } from '../../../store';
import { useAuthStore } from '../../../store';
import { SettingsSection } from './SettingsSection'; import { SettingsSection } from './SettingsSection';
import { SwitchItem } from './SwitchItem'; import { SwitchItem } from './SwitchItem';
@@ -19,7 +18,7 @@ export const NotificationSettings: React.FC<NotificationSettingsProps> = ({
onPushToggle, onPushToggle,
delay = 360, delay = 360,
}) => { }) => {
const { user } = useAuthStore(); const { user, partner } = useAuthStore();
const { preferences, isUpdating, fetchPreferences, updatePreference } = useNotificationPreferencesStore(); const { preferences, isUpdating, fetchPreferences, updatePreference } = useNotificationPreferencesStore();
// Fetch notification preferences on mount when user is available // Fetch notification preferences on mount when user is available
@@ -38,6 +37,9 @@ export const NotificationSettings: React.FC<NotificationSettingsProps> = ({
onPackChangesToggle: (value: boolean) => updatePreference('pack_changes_enabled', value), onPackChangesToggle: (value: boolean) => updatePreference('pack_changes_enabled', value),
onNewPacksToggle: (value: boolean) => updatePreference('new_packs_enabled', value), onNewPacksToggle: (value: boolean) => updatePreference('new_packs_enabled', value),
onStreakMilestonesToggle: (value: boolean) => updatePreference('streak_milestones_enabled', value), onStreakMilestonesToggle: (value: boolean) => updatePreference('streak_milestones_enabled', value),
onWeeklySummaryToggle: (value: boolean) => updatePreference('weekly_summary_enabled', value),
onUnpairedRemindersToggle: (value: boolean) => updatePreference('unpaired_reminders_enabled', value),
onCatchupRemindersToggle: (value: boolean) => updatePreference('catchup_reminders_enabled', value),
}), [updatePreference]); }), [updatePreference]);
return ( return (
@@ -133,6 +135,47 @@ export const NotificationSettings: React.FC<NotificationSettingsProps> = ({
onValueChange={handlers.onStreakMilestonesToggle} onValueChange={handlers.onStreakMilestonesToggle}
disabled={isUpdating} disabled={isUpdating}
/> />
<View style={styles.itemDivider} />
<SwitchItem
icon="calendar-outline"
label="Weekly Recap"
description="Sunday summary of your weekly matches"
value={preferences.weekly_summary_enabled}
onValueChange={handlers.onWeeklySummaryToggle}
disabled={isUpdating}
/>
{partner && (
<>
<View style={styles.itemDivider} />
<SwitchItem
icon="time-outline"
label="Catch-up Reminders"
description="Reminders when your partner is ahead"
value={preferences.catchup_reminders_enabled}
onValueChange={handlers.onCatchupRemindersToggle}
disabled={isUpdating}
/>
</>
)}
{!partner && (
<>
<View style={styles.itemDivider} />
<SwitchItem
icon="people-outline"
label="Pairing Reminders"
description="Reminders to share your invite code"
value={preferences.unpaired_reminders_enabled}
onValueChange={handlers.onUnpairedRemindersToggle}
disabled={isUpdating}
/>
</>
)}
</> </>
)} )}
</SettingsSection> </SettingsSection>
@@ -27,6 +27,11 @@ interface SwipeInfoStateLayoutProps {
onPress: () => void; onPress: () => void;
variant?: "primary" | "secondary"; variant?: "primary" | "secondary";
}; };
secondaryAction?: {
label: string;
onPress: () => void;
icon?: keyof typeof Ionicons.glyphMap;
};
} }
export const SwipeInfoStateLayout = ({ export const SwipeInfoStateLayout = ({
@@ -41,6 +46,7 @@ export const SwipeInfoStateLayout = ({
separatorVariant = "rose", separatorVariant = "rose",
labelStyle, labelStyle,
action, action,
secondaryAction,
}: SwipeInfoStateLayoutProps) => ( }: SwipeInfoStateLayoutProps) => (
<GradientBackground> <GradientBackground>
<View style={styles.centerContainer}> <View style={styles.centerContainer}>
@@ -88,6 +94,17 @@ export const SwipeInfoStateLayout = ({
{action.label} {action.label}
</GlassButton> </GlassButton>
)} )}
{secondaryAction && (
<GlassButton
variant="secondary"
onPress={secondaryAction.onPress}
style={{ marginTop: spacing.sm }}
icon={secondaryAction.icon ? <Ionicons name={secondaryAction.icon} size={18} color={colors.text} /> : undefined}
>
{secondaryAction.label}
</GlassButton>
)}
</Animated.View> </Animated.View>
</View> </View>
</GradientBackground> </GradientBackground>
@@ -1,4 +1,7 @@
import { Share } from "react-native";
import { colors } from "../../../theme"; import { colors } from "../../../theme";
import { useAuthStore } from "../../../store";
import { Events } from "../../../lib/analytics";
import { SwipeInfoStateLayout } from "./SwipeInfoStateLayout"; import { SwipeInfoStateLayout } from "./SwipeInfoStateLayout";
interface SwipeNoPartnerStateProps { interface SwipeNoPartnerStateProps {
@@ -8,6 +11,19 @@ interface SwipeNoPartnerStateProps {
export const SwipeNoPartnerState = ({ hasCouple, onPairPress }: SwipeNoPartnerStateProps) => { export const SwipeNoPartnerState = ({ hasCouple, onPairPress }: SwipeNoPartnerStateProps) => {
const accent = colors.premium.rose; const accent = colors.premium.rose;
const { couple } = useAuthStore();
const handleShareCode = async () => {
if (!couple?.invite_code) return;
try {
await Share.share({
message: `Join me on Sauci! Download the app at https://sauci.app and use my invite code to pair up: ${couple.invite_code}`,
});
Events.codeShared();
} catch (error) {
console.error(error);
}
};
return ( return (
<SwipeInfoStateLayout <SwipeInfoStateLayout
@@ -30,6 +46,11 @@ export const SwipeNoPartnerState = ({ hasCouple, onPairPress }: SwipeNoPartnerSt
label: hasCouple ? "View Invite Code" : "Pair Now", label: hasCouple ? "View Invite Code" : "Pair Now",
onPress: onPairPress, onPress: onPairPress,
}} }}
secondaryAction={hasCouple && couple?.invite_code ? {
label: "Share Code",
onPress: handleShareCode,
icon: "share-outline",
} : undefined}
/> />
); );
}; };
+2 -2
View File
@@ -19,7 +19,7 @@ Notifications.setNotificationHandler({
// Types // Types
export interface NotificationData { export interface NotificationData {
type: "match" | "message" | "match_digest" | "partner_activity" | "nudge"; type: "match" | "message" | "match_digest" | "partner_activity" | "nudge" | "weekly_summary";
match_id?: string; match_id?: string;
message_id?: string; message_id?: string;
count?: number; count?: number;
@@ -231,7 +231,7 @@ export function handleNotificationResponse(
return; return;
} }
if (data.type === "match_digest") { if (data.type === "match_digest" || data.type === "weekly_summary") {
router.push("/(app)/matches"); router.push("/(app)/matches");
return; return;
} }
@@ -11,6 +11,9 @@ export interface NotificationPreferences {
pack_changes_enabled: boolean; pack_changes_enabled: boolean;
new_packs_enabled: boolean; new_packs_enabled: boolean;
streak_milestones_enabled: boolean; streak_milestones_enabled: boolean;
weekly_summary_enabled: boolean;
unpaired_reminders_enabled: boolean;
catchup_reminders_enabled: boolean;
created_at: string; created_at: string;
updated_at: string; updated_at: string;
} }
@@ -0,0 +1,398 @@
import "jsr:@supabase/functions-js/edge-runtime.d.ts";
import { createClient } from "https://esm.sh/@supabase/supabase-js@2.45.0?target=deno";
/**
* Send catch-up reminder notifications to the "behind" partner.
* Called by pg_cron daily at 17:00 UTC.
*
* Escalation schedule:
* - First reminder after 24h of pending questions
* - Follow-up reminders every 3 days
*
* Skips if: user active in last 6h, catchup_reminders_enabled = false, no push token
*/
interface ExpoPushMessage {
to: string;
title: string;
body: string;
sound?: string;
data?: Record<string, unknown>;
}
async function sendExpoPushNotification(message: ExpoPushMessage) {
const response = await fetch("https://exp.host/--/api/v2/push/send", {
method: "POST",
headers: {
"Content-Type": "application/json",
Accept: "application/json",
},
body: JSON.stringify(message),
});
if (!response.ok) {
const error = await response.text();
console.error("Expo push error:", error);
throw new Error(`Failed to send push notification: ${error}`);
}
return response.json();
}
Deno.serve(async (req) => {
if (req.method !== "POST" && req.method !== "GET") {
return new Response("Method not allowed", { status: 405 });
}
const supabase = createClient(
Deno.env.get("SUPABASE_URL")!,
Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!
);
try {
const now = new Date();
const twentyFourHoursAgo = new Date(now.getTime() - 24 * 60 * 60 * 1000);
const threeDaysAgo = new Date(now.getTime() - 3 * 24 * 60 * 60 * 1000);
const sixHoursAgo = new Date(now.getTime() - 6 * 60 * 60 * 1000);
// Find all couples and count unanswered questions per user
// A "behind" user has questions their partner answered but they haven't
const { data: behindUsers, error: queryError } = await supabase.rpc(
"get_behind_partners"
);
// If the RPC doesn't exist yet, fall back to a raw query
let candidates: Array<{
behind_user_id: string;
partner_name: string;
pending_count: number;
}>;
if (queryError) {
// Fallback: use SQL directly
const { data, error } = await supabase.from("couples").select(
"id, user1_id, user2_id"
);
if (error) throw error;
if (!data || data.length === 0) {
return new Response(
JSON.stringify({ success: true, sent: 0, message: "No couples" }),
{ headers: { "Content-Type": "application/json" } }
);
}
candidates = [];
for (const couple of data) {
if (!couple.user1_id || !couple.user2_id) continue;
// Count questions each user answered that the other hasn't
for (const [userId, partnerId] of [
[couple.user1_id, couple.user2_id],
[couple.user2_id, couple.user1_id],
] as [string, string][]) {
const { count, error: countError } = await supabase
.from("responses")
.select("question_id", { count: "exact", head: true })
.eq("user_id", partnerId)
.not(
"question_id",
"in",
`(SELECT question_id FROM responses WHERE user_id = '${userId}')`
);
// The above subquery won't work via PostgREST, use a different approach
// Get partner's answered question IDs, then count how many the user hasn't answered
}
}
// Better approach: use execute_sql-style raw query via RPC
// Actually, let's use a simpler approach with two queries
candidates = await findBehindUsers(supabase);
} else {
candidates = behindUsers || [];
}
// Now process each behind user
let sent = 0;
let skipped = 0;
for (const candidate of candidates) {
const { behind_user_id, partner_name, pending_count } = candidate;
// Get user profile
const { data: profile } = await supabase
.from("profiles")
.select("id, push_token, last_active_at")
.eq("id", behind_user_id)
.maybeSingle();
if (!profile?.push_token) {
skipped++;
continue;
}
// Skip if active in last 6 hours
if (profile.last_active_at && profile.last_active_at > sixHoursAgo.toISOString()) {
skipped++;
// Reset tracking since they're active
continue;
}
// Check notification preferences
const { data: prefs } = await supabase
.from("notification_preferences")
.select("catchup_reminders_enabled")
.eq("user_id", behind_user_id)
.maybeSingle();
if (prefs && prefs.catchup_reminders_enabled === false) {
skipped++;
continue;
}
// Get or create tracking record
const { data: tracking } = await supabase
.from("catchup_reminder_tracking")
.select("*")
.eq("user_id", behind_user_id)
.maybeSingle();
const pendingSince = tracking?.pending_since
? new Date(tracking.pending_since)
: null;
const lastReminderAt = tracking?.last_reminder_sent_at
? new Date(tracking.last_reminder_sent_at)
: null;
const reminderCount = tracking?.reminder_count || 0;
// Upsert pending_since if not set
if (!pendingSince) {
await supabase.from("catchup_reminder_tracking").upsert({
user_id: behind_user_id,
pending_since: now.toISOString(),
reminder_count: 0,
});
// Too early for first reminder (just started tracking)
skipped++;
continue;
}
// Check escalation schedule
if (pendingSince > twentyFourHoursAgo) {
// Less than 24h since questions appeared — too early
skipped++;
continue;
}
if (reminderCount === 0) {
// Send first reminder
} else if (lastReminderAt && lastReminderAt > threeDaysAgo) {
// Less than 3 days since last reminder — skip
skipped++;
continue;
}
// Determine notification copy
const name = partner_name || "Your partner";
let title: string;
let body: string;
if (reminderCount === 0) {
title = "Questions waiting";
body = pending_count >= 6
? `${name} has answered ${pending_count} questions you haven't seen yet`
: `${name} answered some questions — your turn!`;
} else {
title = "Still waiting on you";
body = `You have ${pending_count} questions waiting. Don't leave ${name} hanging!`;
}
try {
await sendExpoPushNotification({
to: profile.push_token,
title,
body,
sound: "default",
data: { type: "catchup_reminder" },
});
// Update tracking
await supabase.from("catchup_reminder_tracking").upsert({
user_id: behind_user_id,
pending_since: tracking.pending_since,
last_reminder_sent_at: now.toISOString(),
reminder_count: reminderCount + 1,
});
sent++;
} catch (error) {
console.error(`Failed to send catchup reminder to ${behind_user_id}:`, error);
}
}
// Reset tracking for users who have caught up
await resetCaughtUpUsers(supabase);
console.log(`Catchup reminders: sent=${sent}, skipped=${skipped}`);
return new Response(
JSON.stringify({ success: true, sent, skipped }),
{ headers: { "Content-Type": "application/json" } }
);
} catch (err) {
console.error("Catchup reminder error:", err);
return new Response(
JSON.stringify({ error: "Internal server error" }),
{ status: 500, headers: { "Content-Type": "application/json" } }
);
}
});
/**
* Find users who are "behind" — their partner answered questions they haven't.
* Returns: { behind_user_id, partner_name, pending_count }[]
*/
async function findBehindUsers(
supabase: ReturnType<typeof createClient>
): Promise<Array<{ behind_user_id: string; partner_name: string; pending_count: number }>> {
// Get all active couples
const { data: couples, error } = await supabase
.from("couples")
.select("id, user1_id, user2_id");
if (error || !couples) return [];
const results: Array<{ behind_user_id: string; partner_name: string; pending_count: number }> = [];
for (const couple of couples) {
if (!couple.user1_id || !couple.user2_id) continue;
// Get enabled pack IDs for this couple
const { data: enabledPacks } = await supabase
.from("couple_packs")
.select("pack_id")
.eq("couple_id", couple.id)
.eq("is_enabled", true);
const packIds = (enabledPacks || []).map((p: { pack_id: string }) => p.pack_id);
if (packIds.length === 0) continue;
// For each direction, count questions partner answered that user hasn't
for (const [userId, partnerId] of [
[couple.user1_id, couple.user2_id],
[couple.user2_id, couple.user1_id],
] as [string, string][]) {
// Get partner's answered question IDs (in enabled packs)
const { data: partnerResponses } = await supabase
.from("responses")
.select("question_id")
.eq("user_id", partnerId);
if (!partnerResponses || partnerResponses.length === 0) continue;
const partnerQuestionIds = partnerResponses.map((r: { question_id: string }) => r.question_id);
// Get user's answered question IDs
const { data: userResponses } = await supabase
.from("responses")
.select("question_id")
.eq("user_id", userId);
const userQuestionIds = new Set(
(userResponses || []).map((r: { question_id: string }) => r.question_id)
);
// Count questions partner answered that user hasn't
const pendingCount = partnerQuestionIds.filter(
(qid: string) => !userQuestionIds.has(qid)
).length;
if (pendingCount > 0) {
// Get partner name
const { data: partnerProfile } = await supabase
.from("profiles")
.select("name")
.eq("id", partnerId)
.maybeSingle();
results.push({
behind_user_id: userId,
partner_name: partnerProfile?.name || "Your partner",
pending_count: pendingCount,
});
}
}
}
return results;
}
/**
* Reset tracking for users who have no pending questions (caught up).
*/
async function resetCaughtUpUsers(
supabase: ReturnType<typeof createClient>
): Promise<void> {
// Get all tracked users
const { data: tracked } = await supabase
.from("catchup_reminder_tracking")
.select("user_id")
.not("pending_since", "is", null);
if (!tracked || tracked.length === 0) return;
for (const { user_id } of tracked) {
// Get user's couple
const { data: profile } = await supabase
.from("profiles")
.select("couple_id")
.eq("id", user_id)
.maybeSingle();
if (!profile?.couple_id) {
// No couple — clear tracking
await supabase
.from("catchup_reminder_tracking")
.update({ pending_since: null, reminder_count: 0 })
.eq("user_id", user_id);
continue;
}
// Get partner
const { data: partner } = await supabase
.from("profiles")
.select("id")
.eq("couple_id", profile.couple_id)
.neq("id", user_id)
.maybeSingle();
if (!partner) continue;
// Check if partner has any responses user hasn't answered
const { data: partnerResponses } = await supabase
.from("responses")
.select("question_id")
.eq("user_id", partner.id);
const { data: userResponses } = await supabase
.from("responses")
.select("question_id")
.eq("user_id", user_id);
const userQuestionIds = new Set(
(userResponses || []).map((r: { question_id: string }) => r.question_id)
);
const pendingCount = (partnerResponses || []).filter(
(r: { question_id: string }) => !userQuestionIds.has(r.question_id)
).length;
if (pendingCount === 0) {
await supabase
.from("catchup_reminder_tracking")
.update({ pending_since: null, reminder_count: 0, last_reminder_sent_at: null })
.eq("user_id", user_id);
}
}
}
@@ -0,0 +1,164 @@
import "jsr:@supabase/functions-js/edge-runtime.d.ts";
import { createClient } from "https://esm.sh/@supabase/supabase-js@2.45.0?target=deno";
/**
* Send push notification reminders to unpaired users.
* Called by pg_cron daily at 18:00 UTC.
*
* Targets users where:
* - couple_id IS NULL (no partner yet)
* - push_token IS NOT NULL (can receive notifications)
* - last_unpaired_reminder_at IS NULL or older than 3 days
* - unpaired_reminders_enabled is not false in notification_preferences
*/
const corsHeaders = {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Headers": "authorization, x-client-info, apikey, content-type",
};
const REMINDER_COOLDOWN_DAYS = 3;
interface ExpoPushMessage {
to: string;
title: string;
body: string;
sound?: string;
data?: Record<string, unknown>;
}
async function sendExpoPushNotification(message: ExpoPushMessage) {
const response = await fetch("https://exp.host/--/api/v2/push/send", {
method: "POST",
headers: {
"Content-Type": "application/json",
Accept: "application/json",
},
body: JSON.stringify(message),
});
if (!response.ok) {
const error = await response.text();
console.error("Expo push error:", error);
throw new Error(`Failed to send push notification: ${error}`);
}
return response.json();
}
Deno.serve(async (req) => {
// Handle CORS preflight
if (req.method === "OPTIONS") {
return new Response("ok", { headers: corsHeaders });
}
if (req.method !== "POST") {
return new Response("Method not allowed", { status: 405, headers: corsHeaders });
}
try {
const supabase = createClient(
Deno.env.get("SUPABASE_URL")!,
Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!
);
const now = new Date();
const cutoff = new Date(now.getTime() - REMINDER_COOLDOWN_DAYS * 24 * 60 * 60 * 1000);
// Get unpaired users with push tokens who haven't been reminded recently
const { data: users, error: usersError } = await supabase
.from("profiles")
.select("id, push_token, couple_id, last_unpaired_reminder_at")
.is("couple_id", null)
.not("push_token", "is", null)
.or(`last_unpaired_reminder_at.is.null,last_unpaired_reminder_at.lt.${cutoff.toISOString()}`);
if (usersError) {
console.error("Error fetching unpaired users:", usersError);
return new Response(
JSON.stringify({ error: "Failed to fetch users" }),
{ status: 500, headers: { ...corsHeaders, "Content-Type": "application/json" } }
);
}
if (!users || users.length === 0) {
return new Response(
JSON.stringify({ success: true, sent: 0, message: "No eligible users" }),
{ headers: { ...corsHeaders, "Content-Type": "application/json" } }
);
}
// Get notification preferences for these users to check opt-out
const userIds = users.map((u) => u.id);
const { data: prefs } = await supabase
.from("notification_preferences")
.select("user_id, unpaired_reminders_enabled")
.in("user_id", userIds);
const disabledUsers = new Set(
(prefs || [])
.filter((p) => p.unpaired_reminders_enabled === false)
.map((p) => p.user_id)
);
// Check which users have a couple (invite code created but no partner yet)
const { data: couples } = await supabase
.from("couples")
.select("id, user1_id")
.in("user1_id", userIds);
const usersWithCouple = new Set(
(couples || []).map((c) => c.user1_id)
);
let sent = 0;
let skipped = 0;
for (const user of users) {
// Skip if user opted out
if (disabledUsers.has(user.id)) {
skipped++;
continue;
}
const hasCouple = usersWithCouple.has(user.id);
const title = "Sauci";
const body = hasCouple
? "Your invite code is waiting! Share it with your partner to start discovering what you have in common."
: "Ready to get started? Create an invite code and share it with your partner!";
try {
await sendExpoPushNotification({
to: user.push_token,
title,
body,
sound: "default",
data: { type: "unpaired_reminder" },
});
// Update last_unpaired_reminder_at
await supabase
.from("profiles")
.update({ last_unpaired_reminder_at: now.toISOString() })
.eq("id", user.id);
sent++;
} catch (error) {
console.error(`Failed to send reminder to ${user.id}:`, error);
}
}
console.log(`Unpaired reminders: sent=${sent}, skipped=${skipped}, total=${users.length}`);
return new Response(
JSON.stringify({ success: true, sent, skipped, total: users.length }),
{ headers: { ...corsHeaders, "Content-Type": "application/json" } }
);
} catch (error) {
console.error("Unpaired reminder error:", error);
return new Response(
JSON.stringify({ error: "Internal server error" }),
{ status: 500, headers: { ...corsHeaders, "Content-Type": "application/json" } }
);
}
});
@@ -0,0 +1,213 @@
import "jsr:@supabase/functions-js/edge-runtime.d.ts";
import { createClient } from "https://esm.sh/@supabase/supabase-js@2.45.0?target=deno";
interface ExpoPushMessage {
to: string;
title: string;
body: string;
sound?: string;
data?: Record<string, unknown>;
}
async function sendExpoPushNotifications(messages: ExpoPushMessage[]) {
if (messages.length === 0) return;
const response = await fetch("https://exp.host/--/api/v2/push/send", {
method: "POST",
headers: {
"Content-Type": "application/json",
Accept: "application/json",
},
body: JSON.stringify(messages),
});
if (!response.ok) {
const error = await response.text();
console.error("Expo push error:", error);
throw new Error(`Failed to send push notification: ${error}`);
}
return response.json();
}
function buildNotificationBody(matchCount: number): string {
if (matchCount === 0) {
return "No new matches this week. Answer some questions to discover what you have in common!";
}
if (matchCount <= 3) {
return `You matched on ${matchCount} topic${matchCount === 1 ? "" : "s"} this week! Tap to start a conversation.`;
}
return `${matchCount} matches this week — you two are on a roll!`;
}
Deno.serve(async (req) => {
if (req.method !== "POST" && req.method !== "GET") {
return new Response("Method not allowed", { status: 405 });
}
const supabase = createClient(
Deno.env.get("SUPABASE_URL")!,
Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!
);
try {
const now = new Date();
const oneWeekAgo = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000).toISOString();
// Get all couples that had at least one match this week
const { data: recentMatches, error: matchError } = await supabase
.from("matches")
.select("couple_id")
.gte("created_at", oneWeekAgo);
if (matchError) {
console.error("Error fetching recent matches:", matchError);
return new Response(
JSON.stringify({ error: matchError.message }),
{ status: 500, headers: { "Content-Type": "application/json" } }
);
}
// Get unique couple IDs that had matches
const couplesWithMatches = new Set<string>(
(recentMatches || []).map((m: { couple_id: string }) => m.couple_id)
);
// Count matches per couple
const matchCountByCouple = new Map<string, number>();
for (const m of recentMatches || []) {
matchCountByCouple.set(m.couple_id, (matchCountByCouple.get(m.couple_id) || 0) + 1);
}
// Also get couples where users answered questions but got no matches
const { data: recentResponses, error: responseError } = await supabase
.from("responses")
.select("user_id")
.gte("created_at", oneWeekAgo);
if (responseError) {
console.error("Error fetching recent responses:", responseError);
return new Response(
JSON.stringify({ error: responseError.message }),
{ status: 500, headers: { "Content-Type": "application/json" } }
);
}
// Get couple_ids for users with responses
const activeUserIds = [...new Set((recentResponses || []).map((r: { user_id: string }) => r.user_id))];
let activeCoupleIds = new Set<string>(couplesWithMatches);
if (activeUserIds.length > 0) {
const { data: activeProfiles } = await supabase
.from("profiles")
.select("couple_id")
.in("id", activeUserIds)
.not("couple_id", "is", null);
for (const p of activeProfiles || []) {
if (p.couple_id) activeCoupleIds.add(p.couple_id);
}
}
if (activeCoupleIds.size === 0) {
return new Response(
JSON.stringify({ sent: 0, message: "No active couples this week" }),
{ headers: { "Content-Type": "application/json" } }
);
}
// Get all users in active couples with push tokens
const { data: users, error: usersError } = await supabase
.from("profiles")
.select("id, couple_id, push_token")
.in("couple_id", [...activeCoupleIds])
.not("push_token", "is", null);
if (usersError) {
console.error("Error fetching users:", usersError);
return new Response(
JSON.stringify({ error: usersError.message }),
{ status: 500, headers: { "Content-Type": "application/json" } }
);
}
if (!users || users.length === 0) {
return new Response(
JSON.stringify({ sent: 0, message: "No users with push tokens" }),
{ headers: { "Content-Type": "application/json" } }
);
}
// Check notification preferences for all users
const userIds = users.map((u: { id: string }) => u.id);
const { data: allPrefs } = await supabase
.from("notification_preferences")
.select("user_id, weekly_summary_enabled")
.in("user_id", userIds);
const prefsMap = new Map<string, boolean>();
for (const p of allPrefs || []) {
prefsMap.set(p.user_id, p.weekly_summary_enabled);
}
// Verify each user has a partner (couple has 2 members)
const coupleUserCounts = new Map<string, number>();
for (const u of users) {
coupleUserCounts.set(u.couple_id, (coupleUserCounts.get(u.couple_id) || 0) + 1);
}
const messages: ExpoPushMessage[] = [];
let skippedPrefs = 0;
let skippedNoPartner = 0;
for (const user of users) {
// Skip if preference explicitly disabled
if (prefsMap.get(user.id) === false) {
skippedPrefs++;
continue;
}
// Skip if no partner in this couple (solo user)
// Note: we only checked users with push tokens, partner may exist without token
// So do a simpler check - just send to all eligible users in couples
const matchCount = matchCountByCouple.get(user.couple_id) || 0;
const body = buildNotificationBody(matchCount);
messages.push({
to: user.push_token!,
title: "Your weekly recap",
body,
sound: "default",
data: { type: "weekly_summary" },
});
}
if (messages.length > 0) {
// Batch in groups of 100 (Expo limit)
for (let i = 0; i < messages.length; i += 100) {
const batch = messages.slice(i, i + 100);
await sendExpoPushNotifications(batch);
}
}
console.log(
`Sent ${messages.length} weekly summary notifications, skipped: prefs=${skippedPrefs}, no partner=${skippedNoPartner}`
);
return new Response(
JSON.stringify({
sent: messages.length,
skipped: { prefs: skippedPrefs, noPartner: skippedNoPartner },
activeCouples: activeCoupleIds.size,
}),
{ headers: { "Content-Type": "application/json" } }
);
} catch (err) {
console.error("Unexpected error:", err);
return new Response(
JSON.stringify({ error: "Internal server error" }),
{ status: 500, headers: { "Content-Type": "application/json" } }
);
}
});
@@ -0,0 +1,142 @@
-- Helper function: sends a Discord notification via the send-discord-notification edge function
CREATE OR REPLACE FUNCTION public.notify_discord_event(p_event text, p_payload jsonb)
RETURNS void
LANGUAGE plpgsql
SECURITY DEFINER
AS $$
BEGIN
PERFORM net.http_post(
url := 'https://ckjcrkjpmhqhiucifukx.supabase.co/functions/v1/send-discord-notification',
headers := jsonb_build_object('Content-Type', 'application/json'),
body := jsonb_build_object(
'event', p_event,
'payload', p_payload
)
);
END;
$$;
-- Trigger function: new user signup
CREATE OR REPLACE FUNCTION public.notify_new_user_discord()
RETURNS trigger
LANGUAGE plpgsql
SECURITY DEFINER
AS $$
BEGIN
PERFORM public.notify_discord_event(
'new_user',
jsonb_build_object(
'user_id', NEW.id,
'name', NEW.name,
'email', NEW.email,
'created_at', NEW.created_at
)
);
RETURN NEW;
END;
$$;
-- Trigger function: couple paired (fires when second partner joins)
CREATE OR REPLACE FUNCTION public.notify_couple_paired_discord()
RETURNS trigger
LANGUAGE plpgsql
SECURITY DEFINER
AS $$
DECLARE
v_member_count integer;
v_profiles jsonb;
BEGIN
IF NEW.couple_id IS NULL OR OLD.couple_id IS NOT DISTINCT FROM NEW.couple_id THEN
RETURN NEW;
END IF;
SELECT COUNT(*)
INTO v_member_count
FROM public.profiles
WHERE couple_id = NEW.couple_id;
IF v_member_count = 2 THEN
SELECT jsonb_agg(
jsonb_build_object(
'id', p.id,
'name', p.name,
'email', p.email
)
ORDER BY p.created_at
)
INTO v_profiles
FROM public.profiles p
WHERE p.couple_id = NEW.couple_id;
PERFORM public.notify_discord_event(
'couple_paired',
jsonb_build_object(
'couple_id', NEW.couple_id,
'profiles', COALESCE(v_profiles, '[]'::jsonb),
'paired_at', now()
)
);
END IF;
RETURN NEW;
END;
$$;
-- Trigger function: feedback submitted
CREATE OR REPLACE FUNCTION public.notify_feedback_submitted_discord()
RETURNS trigger
LANGUAGE plpgsql
SECURITY DEFINER
AS $$
DECLARE
v_user jsonb;
BEGIN
SELECT jsonb_build_object(
'id', p.id,
'name', p.name,
'email', p.email
)
INTO v_user
FROM public.profiles p
WHERE p.id = NEW.user_id;
PERFORM public.notify_discord_event(
'feedback_submitted',
jsonb_build_object(
'feedback_id', NEW.id,
'user_id', NEW.user_id,
'type', NEW.type,
'title', NEW.title,
'description', NEW.description,
'status', NEW.status,
'screenshot_url', NEW.screenshot_url,
'question_id', NEW.question_id,
'created_at', NEW.created_at,
'user', v_user
)
);
RETURN NEW;
END;
$$;
-- Create triggers (drop first to be idempotent)
DROP TRIGGER IF EXISTS on_profile_created_discord ON public.profiles;
CREATE TRIGGER on_profile_created_discord
AFTER INSERT ON public.profiles
FOR EACH ROW
EXECUTE FUNCTION public.notify_new_user_discord();
DROP TRIGGER IF EXISTS on_profile_couple_paired_discord ON public.profiles;
CREATE TRIGGER on_profile_couple_paired_discord
AFTER UPDATE OF couple_id ON public.profiles
FOR EACH ROW
WHEN (OLD.couple_id IS DISTINCT FROM NEW.couple_id AND NEW.couple_id IS NOT NULL)
EXECUTE FUNCTION public.notify_couple_paired_discord();
DROP TRIGGER IF EXISTS on_feedback_submitted_discord ON public.feedback;
CREATE TRIGGER on_feedback_submitted_discord
AFTER INSERT ON public.feedback
FOR EACH ROW
EXECUTE FUNCTION public.notify_feedback_submitted_discord();
@@ -0,0 +1,16 @@
-- Add weekly_summary_enabled to notification_preferences
ALTER TABLE notification_preferences
ADD COLUMN IF NOT EXISTS weekly_summary_enabled BOOLEAN DEFAULT true;
-- Schedule weekly summary notification: Sunday 10:00 UTC
SELECT cron.schedule(
'send-weekly-summary',
'0 10 * * 0',
$$
SELECT net.http_post(
url := 'https://ckjcrkjpmhqhiucifukx.supabase.co/functions/v1/send-weekly-summary',
headers := jsonb_build_object('Content-Type', 'application/json'),
body := '{}'::jsonb
)
$$
);
@@ -0,0 +1,20 @@
-- Add tracking column for unpaired reminder rate limiting
ALTER TABLE profiles
ADD COLUMN IF NOT EXISTS last_unpaired_reminder_at TIMESTAMPTZ;
-- Add preference toggle for unpaired reminders
ALTER TABLE notification_preferences
ADD COLUMN IF NOT EXISTS unpaired_reminders_enabled BOOLEAN DEFAULT true;
-- Schedule daily cron job at 18:00 UTC to send unpaired reminders
SELECT cron.schedule(
'send-unpaired-reminders',
'0 18 * * *',
$$
SELECT net.http_post(
url := 'https://ckjcrkjpmhqhiucifukx.supabase.co/functions/v1/send-unpaired-reminder',
headers := jsonb_build_object('Content-Type', 'application/json'),
body := '{}'::jsonb
)
$$
);
@@ -0,0 +1,27 @@
-- Catchup reminder tracking table
CREATE TABLE IF NOT EXISTS catchup_reminder_tracking (
user_id UUID PRIMARY KEY REFERENCES profiles(id) ON DELETE CASCADE,
pending_since TIMESTAMPTZ,
last_reminder_sent_at TIMESTAMPTZ,
reminder_count INTEGER NOT NULL DEFAULT 0
);
-- Enable RLS (service role only)
ALTER TABLE catchup_reminder_tracking ENABLE ROW LEVEL SECURITY;
-- Add preference toggle for catchup reminders
ALTER TABLE notification_preferences
ADD COLUMN IF NOT EXISTS catchup_reminders_enabled BOOLEAN DEFAULT true;
-- Schedule daily cron job at 17:00 UTC to send catchup reminders
SELECT cron.schedule(
'send-catchup-reminders',
'0 17 * * *',
$$
SELECT net.http_post(
url := 'https://ckjcrkjpmhqhiucifukx.supabase.co/functions/v1/send-catchup-reminder',
headers := jsonb_build_object('Content-Type', 'application/json'),
body := '{}'::jsonb
)
$$
);