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
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
---
@@ -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 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
- 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
@@ -28,26 +29,28 @@ Generate a complete question pack following all Sauci content guidelines.
Extract from $ARGUMENTS:
- **Pack name**: Required - the name for this pack
- **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
- **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
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 intensity levels are covered?
- Where does this new pack fit in the progression?
- What question types are represented?
- Where does this new pack fit?
2. Check if category is explicit:
```sql
SELECT id, name, is_explicit FROM categories WHERE name ILIKE '%<category>%';
```
3. If new category, establish the progression plan:
- What will the beginner pack cover?
- What will intermediate cover?
- What will advanced cover?
3. Check the current type distribution if adding to an existing pack:
```sql
SELECT question_type, count(*) FROM questions
WHERE pack_id = '[pack_id]' AND deleted_at IS NULL
GROUP BY question_type ORDER BY count DESC;
```
### Step 3: Generate Questions
@@ -74,9 +77,60 @@ Follow these rules strictly:
**For NON-EXPLICIT categories (is_explicit = false):**
- CRITICAL: No sexual acts, crude language, or NSFW content
- 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)
- **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)
- 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):
@@ -114,12 +170,9 @@ This ensures:
2. The database tracks which questions are inverses of each other
3. Accurate unique question counts (200 questions = 100 unique concepts with inverses)
**Database Schema:**
- `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.7 Intensity Guidelines
#### 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 |
|-------|------|-------------|----------|
@@ -129,7 +182,7 @@ This ensures:
| 4 | Steamy | Explicit sex, moderate adventure | intercourse, light bondage, anal play |
| 5 | Intense | Advanced/BDSM/extreme | impact play, power dynamics, taboo kinks |
#### 3.6 Targeting Rules
#### 3.8 Targeting Rules
**Couple Targeting (allowed_couple_genders):**
- DEFAULT: null (ALL couples) unless explicit anatomical requirement
@@ -140,19 +193,13 @@ This ensures:
**Initiator Targeting (target_user_genders):**
- DEFAULT: null (anyone can initiate)
- Only for asymmetric questions (those with partner_text)
- 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
- Only for asymmetric swipe questions (those with partner_text)
- Set based on who does the action in "text" field
- When in doubt, use null
**Anatomical Consistency:**
- 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
**Note:** who_likely, audio, and photo questions are always NULL for both targeting fields (they're symmetric and gender-neutral).
#### 3.7 Required Props
#### 3.9 Required Props
Identify physical items/accessories needed:
- 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:**
- [ ] 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 time-specific words
- [ ] 5-12 words (concise)
- [ ] No cheesy/cliche language
- [ ] 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:**
- [ ] 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 frames responses as "allowing" not forced
- [ ] No actual duplicates (same `text` appearing twice)
@@ -190,7 +244,7 @@ Before outputting, verify each question against this checklist:
**Consistency:**
- [ ] No mixed anatomy in alternatives
- [ ] Tone matches explicit/non-explicit category
- [ ] Fits progression within category
- [ ] Fits within the category
### Step 5: Output
@@ -207,55 +261,50 @@ VALUES (
)
RETURNING id;
-- Then insert questions with inverse_of linking
-- For inverse pairs: insert primary first (inverse_of = NULL), then inverse pointing to primary
-- Then insert questions
-- For swipe inverse pairs: insert primary first (inverse_of = NULL), then inverse pointing to primary
-- Generate UUIDs for inverse pairs upfront
-- Pair 1: Primary and its inverse
-- Swipe pair example
WITH pair1_primary AS (
INSERT INTO questions (pack_id, text, partner_text, intensity, inverse_of)
VALUES ('[pack_id]', 'Spank your partner', 'Be spanked by your partner', 3, NULL)
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, 'swipe', '{}', NULL)
RETURNING id
)
INSERT INTO questions (pack_id, text, partner_text, intensity, inverse_of)
SELECT '[pack_id]', 'Be spanked by your partner', 'Spank your partner', 3, id
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, 'swipe', '{}', id
FROM pair1_primary;
-- Symmetric questions (no inverse_of needed)
INSERT INTO questions (pack_id, text, partner_text, intensity, allowed_couple_genders, target_user_genders, required_props, inverse_of) VALUES
('[pack_id]', 'Symmetric question text', NULL, 2, NULL, NULL, NULL, NULL);
-- Symmetric swipe (no inverse needed)
INSERT INTO questions (pack_id, text, partner_text, intensity, question_type, config, inverse_of) VALUES
('[pack_id]', 'Cook dinner together', NULL, 1, 'swipe', '{}', NULL);
-- Or bulk insert with explicit UUIDs for pairs:
INSERT INTO questions (id, pack_id, text, partner_text, intensity, inverse_of) VALUES
-- Pair 1
('uuid-primary-1', '[pack_id]', 'Give your partner a massage', 'Receive a massage from your partner', 2, NULL),
('uuid-inverse-1', '[pack_id]', 'Receive a massage from your partner', 'Give your partner a massage', 2, 'uuid-primary-1'),
-- Pair 2
('uuid-primary-2', '[pack_id]', 'Blindfold your partner', 'Be blindfolded by your partner', 3, NULL),
('uuid-inverse-2', '[pack_id]', 'Be blindfolded by your partner', 'Blindfold your partner', 3, 'uuid-primary-2'),
-- Symmetric (no pair)
(gen_random_uuid(), '[pack_id]', 'Cook dinner together', NULL, 1, NULL);
-- who_likely questions (always symmetric, no inverse)
INSERT INTO questions (pack_id, text, partner_text, intensity, question_type, config, inverse_of) VALUES
('[pack_id]', 'Who is more likely to plan a surprise date?', NULL, 1, 'who_likely', '{}', NULL),
('[pack_id]', 'Who is more likely to cry during a movie?', NULL, 1, 'who_likely', '{}', NULL);
-- audio questions (always symmetric, no inverse)
INSERT INTO questions (pack_id, text, partner_text, intensity, question_type, config, inverse_of) VALUES
('[pack_id]', 'Describe your favourite memory together', NULL, 1, 'audio', '{"max_duration_seconds": 60}', NULL),
('[pack_id]', 'Record your best impression of your partner', NULL, 1, 'audio', '{"max_duration_seconds": 60}', 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:
| Metric | Value |
|--------|-------|
| Total questions | X |
| Unique question concepts | X (total - inverse count) |
| Intensity 1 | X |
| Intensity 2 | X |
| Intensity 3 | X |
| Intensity 4 | X |
| Intensity 5 | X |
| Swipe questions | X |
| who_likely questions | X |
| audio questions | X |
| photo questions | 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 initiator targeting | X |
| With required props | X |
@@ -263,21 +312,24 @@ Also provide a summary table:
## Example Usage
```
/generate-pack "Testing the Waters" --category="Public Thrills" --intensity=2-3 --count=30
/generate-pack "Kink Discovery" --category="The Kink Lab" --intensity=3-5 --count=50
/generate-pack "Staying Close" --category=new:"Long Distance" --intensity=1 --count=30
/generate-pack "Testing the Waters" --category="Public Thrills" --count=30
/generate-pack "Kink Discovery" --category="The Kink Lab" --count=50 --premium
/generate-pack "Staying Close" --category=new:"Long Distance" --count=30
/generate-pack "Date Night" --category="Quality Time" --types=who_likely,audio,photo
```
## Guidelines
- DO ask clarifying questions if the pack concept is unclear
- 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 use the inverse_of column to link inverse pairs (inverse points to primary's UUID)
- DON'T use cheesy or cliche language
- DON'T assume heterosexual couples
- DON'T skip the inverse question requirement
- DON'T forget to set inverse_of - this is how we track unique question concepts
- DON'T skip the inverse question requirement for asymmetric swipe questions
- 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 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 typecheck
- 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
- **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:
- Migration history is not committed to git
- Other developers won't get the schema changes
- `supabase db reset` won't include MCP-applied migrations
- Changes cannot be reproduced from scratch
#### The Golden Rule
**ALL schema changes MUST go through local migration files.** No exceptions.
#### 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
# 1. Create migration file locally
supabase migration new <descriptive_name>
# 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
# 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 db push
```
**MCP tools ARE appropriate for:**
- Reading data (`execute_sql` for SELECT queries)
- Exploring schema (`list_tables`, `list_migrations`)
- Deploying edge functions (`deploy_edge_function`)
- Generating types (`generate_typescript_types`)
#### MCP Tools - What IS Allowed
- **Reading data:** `execute_sql` with SELECT queries only
- **Exploring schema:** `list_tables`, `list_migrations`, `list_extensions`
- **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
@@ -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)
- `send-notification` - Push notifications for matches
- `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
- `sync-subscription` - Syncs RevenueCat subscription status
- `redeem-code` - Redeems promotional codes for premium access (website only)
@@ -167,6 +196,8 @@ This approach:
| `send-notification` | false | Internal trigger (no auth) |
| `send-message-notification` | false | Internal trigger (no auth) |
| `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
- **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
Each question has:
- `text` - What Partner A sees
- `partner_text` - What Partner B sees (can be NULL)
- `intensity` - 1-5 scale
- `partner_text` - What Partner B sees (NULL for symmetric questions and all non-swipe types)
- `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)
- `target_user_genders` - Which gender initiates (NULL = any)
- `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:
```
@@ -58,9 +118,11 @@ text: "Give your partner a massage"
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):**
```
@@ -86,8 +148,7 @@ This ensures:
**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.
- The `inverse_of` column links the pair: the inverse question points to the primary question's UUID
- This allows the app to display accurate unique question counts (100 concepts, not 200 questions)
- Non-swipe types (who_likely, audio, photo) NEVER have inverses — they are always symmetric.
---
@@ -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 |
| Sex toys | NULL | Toys work for everyone |
Examples:
- "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
**Note:** who_likely, audio, and photo questions are always NULL (they're gender-neutral by nature).
### 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).
Only set when the `text` field requires specific anatomy:
- "Swallow your partner's cum" → 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.
@@ -154,6 +210,8 @@ Identify physical items/accessories needed for the activity.
## 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
Pure emotional connection, non-sexual bonding
- Examples: Cook together, watch movies, take walks, game nights
@@ -161,7 +219,7 @@ Pure emotional connection, non-sexual bonding
### Level 2 - WARM
Romantic atmosphere, affectionate touch
- Examples: Slow dancing, candlelit dinners, extended kissing, cuddling
- Examples: Slow dancing, extended kissing, cuddling
- Romantic but not explicit
### 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
### Non-Sexual Categories (intensity 1-2)
- Social Life, Quality Time, Kitchen Chemistry
- Focus on bonding, activities, preferences
- partner_text usually NULL (symmetric activities)
- Great fit for who_likely, audio, and photo questions
### Sexual Categories (intensity 3-5)
- Toy Time, Public Thrills, Kink Lab, Bedroom Adventures
- Mix of symmetric and asymmetric questions
- Always create inverse questions for asymmetric content
- Mix of symmetric and asymmetric swipe questions
- Always create inverse questions for asymmetric swipe content
- who_likely questions can still work here ("Who is more likely to initiate?")
### Mixed Categories
- Long Distance, Romance & Sensuality
- 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
- Include multiple perspectives per kink/activity
- 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:
- Use cheesy phrases ("make love", "intimate connection", "souls intertwining")
- 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
- Be preachy or educational in tone
- Include judgment about activities
@@ -257,22 +300,23 @@ When asked to create a pack:
1. **Understand the category and position**
- What category does this belong to?
- Where in the progression is this pack? (beginner/intermediate/advanced)
- What intensity range?
- What packs already exist?
- Is the category explicit or non-explicit?
2. **Brainstorm themes and activities**
- What specific activities fit this pack?
- What makes this pack different from adjacent packs?
- What progression from previous pack?
- What question types fit best for this theme?
3. **Generate questions**
- Mix of symmetric (null partner_text) and asymmetric questions
- For EVERY asymmetric question, immediately create its inverse
- Mix of swipe, who_likely, audio, and photo types
- 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
- Target ~30-50 questions per pack
4. **Review for balance**
- Check intensity distribution
- Check type distribution
- Ensure all perspectives covered
- Remove duplicates or too-similar questions
- Verify no cliches or cheesy language
@@ -286,137 +330,125 @@ When asked to create a pack:
## Output Format
When generating packs, output in this format (with inverse_of linking for pairs):
When generating packs, output in this format:
```sql
-- Pack: [Pack Name]
-- Category: [Category Name]
-- Description: [Pack description]
-- Intensity range: [X-Y]
-- For 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
-- Pair 1
('uuid-primary-1', '[pack_id]', 'Spank your partner', 'Be spanked by your partner', 3, NULL, NULL, NULL, NULL),
('uuid-inverse-1', '[pack_id]', 'Be spanked by your partner', 'Spank your partner', 3, NULL, NULL, NULL, 'uuid-primary-1'),
-- Pair 2 (with targeting)
('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),
('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'),
-- Symmetric (no inverse needed)
(gen_random_uuid(), '[pack_id]', 'Cook dinner together', NULL, 1, NULL, NULL, NULL, NULL);
-- Swipe inverse pairs: insert primary first (inverse_of = NULL), then inverse pointing to primary
INSERT INTO questions (id, pack_id, text, partner_text, intensity, question_type, config, allowed_couple_genders, target_user_genders, required_props, inverse_of) VALUES
-- Swipe pair 1
('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, 'swipe', '{}', NULL, NULL, NULL, 'uuid-primary-1'),
-- Symmetric swipe (no inverse)
(gen_random_uuid(), '[pack_id]', 'Cook dinner together', NULL, 1, 'swipe', '{}', NULL, NULL, NULL, NULL),
-- who_likely (always symmetric, no inverse)
(gen_random_uuid(), '[pack_id]', 'Who is more likely to burn dinner?', NULL, 1, 'who_likely', '{}', 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
### Non-Sexual (Intensity 1)
### Swipe - Non-Sexual (Intensity 1)
```
text: "Cook the same recipe together over video call"
partner_text: NULL
intensity: 1
allowed_couple_genders: NULL
target_user_genders: NULL
required_props: NULL
question_type: swipe
config: {}
```
### Romantic (Intensity 2)
### Swipe - Romantic (Intensity 2)
```
text: "Send a tipsy voice note telling them you miss them"
partner_text: NULL
intensity: 2
allowed_couple_genders: NULL
target_user_genders: NULL
required_props: NULL
question_type: swipe
config: {}
```
### Playful (Intensity 3) - With Props and Inverse Linking
### Swipe - Playful (Intensity 3) - With Props and Inverse
```
id: uuid-blindfold-primary
text: "Blindfold your partner and tease them with different sensations"
partner_text: "Be blindfolded while your partner teases you"
intensity: 3
allowed_couple_genders: NULL
target_user_genders: NULL
question_type: swipe
config: {}
required_props: ["blindfold"]
inverse_of: NULL
```
AND its inverse (linked via inverse_of):
AND its inverse:
```
id: uuid-blindfold-inverse
text: "Be blindfolded while your partner teases you"
partner_text: "Blindfold your partner and tease them with different sensations"
intensity: 3
allowed_couple_genders: NULL
target_user_genders: NULL
question_type: swipe
config: {}
required_props: ["blindfold"]
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"
partner_text: "Wear a remote vibrator at dinner while your partner controls it"
intensity: 4
allowed_couple_genders: NULL
target_user_genders: NULL
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"]
text: "Who is more likely to forget an anniversary?"
partner_text: NULL
intensity: 1
question_type: who_likely
config: {}
```
### 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"
partner_text: "Receive a blowjob from your partner in a risky location"
intensity: 3
allowed_couple_genders: ["male+male", "female+male"] -- requires penis
target_user_genders: NULL
required_props: NULL
question_type: swipe
config: {}
allowed_couple_genders: ["male+male", "female+male"]
```
### With Initiator Targeting (Intensity 4)
```
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)
### Swipe - Intense (Intensity 5)
```
text: "Edge your partner repeatedly, denying release until you decide"
partner_text: "Be edged and denied until your partner allows you to finish"
intensity: 5
allowed_couple_genders: NULL
target_user_genders: NULL
required_props: NULL
question_type: swipe
config: {}
```
AND its inverse:
```
text: "Be edged and denied until your partner allows you to finish"
partner_text: "Edge your partner repeatedly, denying release until you decide"
intensity: 5
allowed_couple_genders: NULL
target_user_genders: NULL
required_props: NULL
question_type: swipe
config: {}
```
---
@@ -425,7 +457,7 @@ required_props: NULL
**Language & Format:**
- [ ] 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 time-specific language (tonight, now, today)
- [ ] 5-12 words per question (concise)
@@ -433,26 +465,35 @@ required_props: NULL
- [ ] Mix of sentence structures
- [ ] 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:**
- [ ] All asymmetric questions have their inverse created (these are NOT duplicates - they're required pairs)
- [ ] All inverse pairs are linked via `inverse_of` column (inverse points to primary's UUID)
- [ ] All asymmetric swipe questions have their inverse created
- [ ] All inverse pairs are linked via `inverse_of` column
- [ ] Partner text is appealing (not clinical)
- [ ] Partner text frames responses as "allowing" not forced
- [ ] No actual duplicates (same `text` appearing twice)
**Database Linking:**
- [ ] Primary questions have `inverse_of = NULL`
- [ ] Inverse questions have `inverse_of = <primary_question_uuid>`
- [ ] Symmetric questions have `inverse_of = NULL`
- [ ] Primary swipe questions have `inverse_of = NULL`
- [ ] Inverse swipe questions have `inverse_of = <primary_question_uuid>`
- [ ] Symmetric swipe questions have `inverse_of = NULL`
- [ ] Non-swipe questions have `inverse_of = NULL`
**Accuracy:**
- [ ] Intensity levels match the activities
- [ ] 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
- [ ] Consider same-sex couples
**Consistency:**
- [ ] No mixed anatomy in alternatives
- [ ] 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,
SelectValue,
} 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 { IconPreview } from '@/components/ui/icon-picker';
@@ -130,6 +130,20 @@ interface FeatureInterest {
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 {
id: string;
couple_id: string;
@@ -277,6 +291,13 @@ export function UserDetailPage() {
const [enabledPacks, setEnabledPacks] = useState<EnabledPack[]>([]);
const [featureInterests, setFeatureInterests] = useState<FeatureInterest[]>([]);
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 [responsesPageSize, setResponsesPageSize] = useState(10);
const [responsesTotal, setResponsesTotal] = useState(0);
@@ -467,19 +488,115 @@ export function UserDetailPage() {
} else {
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 {
setMatches([]);
setMatchesTotal(0);
setMediaMessages([]);
setMediaTotal(0);
setEnabledPacks([]);
setOutstandingQuestions([]);
setOutstandingTotal(0);
setGapDisabled(false);
}
} catch (error) {
console.error('Failed to load user data:', error);
} finally {
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: 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);
setMatchesPage(1);
setMediaPage(1);
setOutstandingPage(1);
}, [userId]);
useEffect(() => {
@@ -526,6 +644,8 @@ export function UserDetailPage() {
<h1 className="text-2xl font-bold">{profile.name || 'Unnamed User'}</h1>
<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>}
{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>}
</div>
<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>}
{canViewMatches && <TabsTrigger value="matches" disabled={!profile.couple_id}>Matches ({matchesTotal})</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>
</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>}
@@ -644,6 +765,86 @@ export function UserDetailPage() {
<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>}
{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">
{enabledPacks.length === 0 ? (
<Card className="flex flex-col items-center justify-center py-12">
+80 -6
View File
@@ -7,6 +7,7 @@ import {
Alert,
ActivityIndicator,
Share,
Linking,
KeyboardAvoidingView,
Platform,
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 () => {
if (couple?.invite_code) {
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}`,
});
await Share.share({ message: shareMessage });
Events.codeShared();
} catch (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 () => {
Alert.alert(
"Cancel Pairing",
@@ -256,7 +276,15 @@ export default function PairingScreen() {
</GlassCard>
</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
entering={FadeInDown.delay(400).duration(500)}
style={styles.section}
@@ -264,12 +292,28 @@ export default function PairingScreen() {
<GlassButton
onPress={shareCode}
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>
</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 */}
<Animated.View
entering={FadeInDown.delay(500).duration(500)}
@@ -559,4 +603,34 @@ const styles = StyleSheet.create({
...typography.subhead,
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 { View, StyleSheet, Text } from 'react-native';
import { colors, spacing, typography } from '../../../theme';
import { useNotificationPreferencesStore } from '../../../store';
import { useAuthStore } from '../../../store';
import { useNotificationPreferencesStore, useAuthStore } from '../../../store';
import { SettingsSection } from './SettingsSection';
import { SwitchItem } from './SwitchItem';
@@ -19,7 +18,7 @@ export const NotificationSettings: React.FC<NotificationSettingsProps> = ({
onPushToggle,
delay = 360,
}) => {
const { user } = useAuthStore();
const { user, partner } = useAuthStore();
const { preferences, isUpdating, fetchPreferences, updatePreference } = useNotificationPreferencesStore();
// 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),
onNewPacksToggle: (value: boolean) => updatePreference('new_packs_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]);
return (
@@ -133,6 +135,47 @@ export const NotificationSettings: React.FC<NotificationSettingsProps> = ({
onValueChange={handlers.onStreakMilestonesToggle}
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>
@@ -27,6 +27,11 @@ interface SwipeInfoStateLayoutProps {
onPress: () => void;
variant?: "primary" | "secondary";
};
secondaryAction?: {
label: string;
onPress: () => void;
icon?: keyof typeof Ionicons.glyphMap;
};
}
export const SwipeInfoStateLayout = ({
@@ -41,6 +46,7 @@ export const SwipeInfoStateLayout = ({
separatorVariant = "rose",
labelStyle,
action,
secondaryAction,
}: SwipeInfoStateLayoutProps) => (
<GradientBackground>
<View style={styles.centerContainer}>
@@ -88,6 +94,17 @@ export const SwipeInfoStateLayout = ({
{action.label}
</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>
</View>
</GradientBackground>
@@ -1,4 +1,7 @@
import { Share } from "react-native";
import { colors } from "../../../theme";
import { useAuthStore } from "../../../store";
import { Events } from "../../../lib/analytics";
import { SwipeInfoStateLayout } from "./SwipeInfoStateLayout";
interface SwipeNoPartnerStateProps {
@@ -8,6 +11,19 @@ interface SwipeNoPartnerStateProps {
export const SwipeNoPartnerState = ({ hasCouple, onPairPress }: SwipeNoPartnerStateProps) => {
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 (
<SwipeInfoStateLayout
@@ -30,6 +46,11 @@ export const SwipeNoPartnerState = ({ hasCouple, onPairPress }: SwipeNoPartnerSt
label: hasCouple ? "View Invite Code" : "Pair Now",
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
export interface NotificationData {
type: "match" | "message" | "match_digest" | "partner_activity" | "nudge";
type: "match" | "message" | "match_digest" | "partner_activity" | "nudge" | "weekly_summary";
match_id?: string;
message_id?: string;
count?: number;
@@ -231,7 +231,7 @@ export function handleNotificationResponse(
return;
}
if (data.type === "match_digest") {
if (data.type === "match_digest" || data.type === "weekly_summary") {
router.push("/(app)/matches");
return;
}
@@ -11,6 +11,9 @@ export interface NotificationPreferences {
pack_changes_enabled: boolean;
new_packs_enabled: boolean;
streak_milestones_enabled: boolean;
weekly_summary_enabled: boolean;
unpaired_reminders_enabled: boolean;
catchup_reminders_enabled: boolean;
created_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
)
$$
);