From c8aaa8967d4d568daa076ea8463551f349794c75 Mon Sep 17 00:00:00 2001 From: Jamie Brunton Date: Sat, 31 Jan 2026 23:38:27 +0000 Subject: [PATCH] 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. --- .claude/commands/generate-pack.md | 194 +++++---- CLAUDE.md | 4 + PACK_GENERATION_AGENT.md | 295 +++++++------ apps/mobile/app/(app)/pairing.tsx | 204 ++++++--- .../components/NotificationSettings.tsx | 37 +- .../swipe/components/SwipeInfoStateLayout.tsx | 17 + .../swipe/components/SwipeNoPartnerState.tsx | 21 + .../src/store/notificationPreferencesStore.ts | 2 + .../functions/send-catchup-reminder/index.ts | 398 ++++++++++++++++++ .../functions/send-unpaired-reminder/index.ts | 164 ++++++++ .../20260131231008_add_unpaired_reminders.sql | 20 + .../20260131232838_add_catchup_reminders.sql | 27 ++ 12 files changed, 1117 insertions(+), 266 deletions(-) create mode 100644 apps/supabase/functions/send-catchup-reminder/index.ts create mode 100644 apps/supabase/functions/send-unpaired-reminder/index.ts create mode 100644 apps/supabase/migrations/20260131231008_add_unpaired_reminders.sql create mode 100644 apps/supabase/migrations/20260131232838_add_catchup_reminders.sql diff --git a/.claude/commands/generate-pack.md b/.claude/commands/generate-pack.md index 5b42c02..c3c9103 100644 --- a/.claude/commands/generate-pack.md +++ b/.claude/commands/generate-pack.md @@ -1,6 +1,6 @@ --- allowed-tools: Read, mcp__sauci-prod__execute_sql, mcp__sauci-prod__list_tables -argument-hint: --category= --intensity= [--count=30] [--premium] +argument-hint: --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 '%%'; ``` -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 = ` +#### 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`) diff --git a/CLAUDE.md b/CLAUDE.md index 80e0d0c..3579a8a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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. diff --git a/PACK_GENERATION_AGENT.md b/PACK_GENERATION_AGENT.md index dea94ed..94a837c 100644 --- a/PACK_GENERATION_AGENT.md +++ b/PACK_GENERATION_AGENT.md @@ -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 = ` -- [ ] Symmetric questions have `inverse_of = NULL` +- [ ] Primary swipe questions have `inverse_of = NULL` +- [ ] Inverse swipe questions have `inverse_of = ` +- [ ] 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 diff --git a/apps/mobile/app/(app)/pairing.tsx b/apps/mobile/app/(app)/pairing.tsx index e1416a5..a7b9408 100755 --- a/apps/mobile/app/(app)/pairing.tsx +++ b/apps/mobile/app/(app)/pairing.tsx @@ -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() { - {/* Share Button */} + {/* CTA Text */} + + Send this to your partner to get started + + + {/* Primary Share Button */} } + size="lg" + icon={} > - Share Code + Share Invite Code + {/* Quick share buttons */} + + + + Messages + + + + WhatsApp + + + {/* Waiting indicator */} = ({ 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 = ({ 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 = ({ onValueChange={handlers.onWeeklySummaryToggle} disabled={isUpdating} /> + + {partner && ( + <> + + + + + )} + + {!partner && ( + <> + + + + + )} )} diff --git a/apps/mobile/src/features/swipe/components/SwipeInfoStateLayout.tsx b/apps/mobile/src/features/swipe/components/SwipeInfoStateLayout.tsx index ee10e41..0c3c540 100644 --- a/apps/mobile/src/features/swipe/components/SwipeInfoStateLayout.tsx +++ b/apps/mobile/src/features/swipe/components/SwipeInfoStateLayout.tsx @@ -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) => ( @@ -88,6 +94,17 @@ export const SwipeInfoStateLayout = ({ {action.label} )} + + {secondaryAction && ( + : undefined} + > + {secondaryAction.label} + + )} diff --git a/apps/mobile/src/features/swipe/components/SwipeNoPartnerState.tsx b/apps/mobile/src/features/swipe/components/SwipeNoPartnerState.tsx index ea912bc..3afdf6e 100644 --- a/apps/mobile/src/features/swipe/components/SwipeNoPartnerState.tsx +++ b/apps/mobile/src/features/swipe/components/SwipeNoPartnerState.tsx @@ -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 ( ); }; diff --git a/apps/mobile/src/store/notificationPreferencesStore.ts b/apps/mobile/src/store/notificationPreferencesStore.ts index 4146813..5afb252 100644 --- a/apps/mobile/src/store/notificationPreferencesStore.ts +++ b/apps/mobile/src/store/notificationPreferencesStore.ts @@ -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; } diff --git a/apps/supabase/functions/send-catchup-reminder/index.ts b/apps/supabase/functions/send-catchup-reminder/index.ts new file mode 100644 index 0000000..c246751 --- /dev/null +++ b/apps/supabase/functions/send-catchup-reminder/index.ts @@ -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; +} + +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 +): Promise> { + // 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 +): Promise { + // 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); + } + } +} diff --git a/apps/supabase/functions/send-unpaired-reminder/index.ts b/apps/supabase/functions/send-unpaired-reminder/index.ts new file mode 100644 index 0000000..f1833da --- /dev/null +++ b/apps/supabase/functions/send-unpaired-reminder/index.ts @@ -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; +} + +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" } } + ); + } +}); diff --git a/apps/supabase/migrations/20260131231008_add_unpaired_reminders.sql b/apps/supabase/migrations/20260131231008_add_unpaired_reminders.sql new file mode 100644 index 0000000..2fd0df3 --- /dev/null +++ b/apps/supabase/migrations/20260131231008_add_unpaired_reminders.sql @@ -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 + ) + $$ +); diff --git a/apps/supabase/migrations/20260131232838_add_catchup_reminders.sql b/apps/supabase/migrations/20260131232838_add_catchup_reminders.sql new file mode 100644 index 0000000..8a29298 --- /dev/null +++ b/apps/supabase/migrations/20260131232838_add_catchup_reminders.sql @@ -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 + ) + $$ +);