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.
This commit is contained in:
jjbrunton committed 2026-01-31 23:38:27 +00:00
1 parent 8a44fef764
commit c8aaa8967d
12 files changed
+1117 -266

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`)
+4
View File
@@ -162,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)
@@ -194,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
+139 -65
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)}
@@ -414,61 +458,61 @@ const styles = StyleSheet.create({
flexDirection: "row",
alignItems: "center",
},
backButton: {
width: 44,
height: 44,
borderRadius: 22,
backgroundColor: colors.backgroundLight,
justifyContent: "center",
alignItems: "center",
marginRight: spacing.md,
borderWidth: 1,
borderColor: colors.border,
},
// ...
input: {
backgroundColor: colors.background, // Flat background
borderRadius: radius.md,
borderWidth: 1,
borderColor: colors.border,
padding: spacing.md,
...typography.headline,
color: colors.text,
textAlign: "center",
letterSpacing: 4,
fontFamily: Platform.OS === "ios" ? "Courier" : "monospace",
marginBottom: spacing.md,
},
// ...
dividerLine: {
flex: 1,
height: 1,
backgroundColor: colors.border,
},
// ...
copyIcon: {
width: 36,
height: 36,
borderRadius: 18,
backgroundColor: colors.background,
justifyContent: "center",
alignItems: "center",
marginLeft: spacing.md,
borderWidth: 1,
borderColor: colors.border,
},
// ...
waitingBadge: {
flexDirection: "row",
alignItems: "center",
backgroundColor: colors.backgroundLight,
paddingHorizontal: spacing.md,
paddingVertical: spacing.sm,
borderRadius: radius.full,
gap: spacing.sm,
borderWidth: 1,
borderColor: colors.border,
},
backButton: {
width: 44,
height: 44,
borderRadius: 22,
backgroundColor: colors.backgroundLight,
justifyContent: "center",
alignItems: "center",
marginRight: spacing.md,
borderWidth: 1,
borderColor: colors.border,
},
// ...
input: {
backgroundColor: colors.background, // Flat background
borderRadius: radius.md,
borderWidth: 1,
borderColor: colors.border,
padding: spacing.md,
...typography.headline,
color: colors.text,
textAlign: "center",
letterSpacing: 4,
fontFamily: Platform.OS === "ios" ? "Courier" : "monospace",
marginBottom: spacing.md,
},
// ...
dividerLine: {
flex: 1,
height: 1,
backgroundColor: colors.border,
},
// ...
copyIcon: {
width: 36,
height: 36,
borderRadius: 18,
backgroundColor: colors.background,
justifyContent: "center",
alignItems: "center",
marginLeft: spacing.md,
borderWidth: 1,
borderColor: colors.border,
},
// ...
waitingBadge: {
flexDirection: "row",
alignItems: "center",
backgroundColor: colors.backgroundLight,
paddingHorizontal: spacing.md,
paddingVertical: spacing.sm,
borderRadius: radius.full,
gap: spacing.sm,
borderWidth: 1,
borderColor: colors.border,
},
title: {
...typography.title1,
color: colors.text,
@@ -508,13 +552,13 @@ const styles = StyleSheet.create({
marginBottom: spacing.sm,
marginLeft: spacing.xs,
},
divider: {
flexDirection: "row",
alignItems: "center",
marginVertical: spacing.lg,
},
dividerText: {
...typography.subhead,
color: colors.textTertiary,
@@ -531,7 +575,7 @@ const styles = StyleSheet.create({
letterSpacing: 6,
fontFamily: Platform.OS === "ios" ? "Courier" : "monospace",
},
tapToCopy: {
...typography.caption1,
color: colors.textTertiary,
@@ -542,7 +586,7 @@ const styles = StyleSheet.create({
alignItems: "center",
marginTop: spacing.xl,
},
waitingText: {
...typography.subhead,
color: colors.textTertiary,
@@ -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
@@ -39,6 +38,8 @@ export const NotificationSettings: React.FC<NotificationSettingsProps> = ({
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 (
@@ -145,6 +146,36 @@ export const NotificationSettings: React.FC<NotificationSettingsProps> = ({
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}
/>
);
};
@@ -12,6 +12,8 @@ export interface NotificationPreferences {
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,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
)
$$
);