fix: use dynamic URLs for cron jobs to prevent cross-env firing

Non-prod cron jobs were hardcoding production URLs, causing double
notifications. Adds get_supabase_edge_function_url() helper that
resolves from app_config.supabase_url per environment. Also adds
text_answer question type to pack generation docs.
This commit is contained in:
jjbrunton committed 2026-02-01 10:12:30 +00:00
1 parent c8aaa8967d
commit 964fc25fc7
4 files changed
+247 -14

No files matched your search

+25 -6
View File
@@ -1,6 +1,6 @@
--- ---
allowed-tools: Read, mcp__sauci-prod__execute_sql, mcp__sauci-prod__list_tables allowed-tools: Read, mcp__sauci-prod__execute_sql, mcp__sauci-prod__list_tables
argument-hint: <pack-name> --category=<category> [--count=30] [--premium] [--types=swipe,who_likely,audio,photo] argument-hint: <pack-name> --category=<category> [--count=30] [--premium] [--types=swipe,who_likely,audio,photo,text_answer]
description: Generate a question pack for Sauci following established content guidelines description: Generate a question pack for Sauci following established content guidelines
--- ---
@@ -31,7 +31,7 @@ Extract from $ARGUMENTS:
- **Category**: Required - which category this belongs to (or "new" to create) - **Category**: Required - which category this belongs to (or "new" to create)
- **Question count**: Optional - default 30, can be 25-50 - **Question count**: Optional - default 30, can be 25-50
- **Premium**: Optional flag - if pack should be premium - **Premium**: Optional flag - if pack should be premium
- **Types**: Optional - comma-separated question types to generate. Default: `swipe,who_likely,audio,photo`. Options: `swipe`, `who_likely`, `audio`, `photo` - **Types**: Optional - comma-separated question types to generate. Default: `swipe,who_likely,audio,photo,text_answer`. Options: `swipe`, `who_likely`, `audio`, `photo`, `text_answer`
### Step 2: Understand Context ### Step 2: Understand Context
@@ -120,13 +120,26 @@ Each question MUST have a `question_type`. The default mix should include variet
- "Take a selfie showing how you feel right now" - "Take a selfie showing how you feel right now"
- "Share a screenshot of your last text about your partner" - "Share a screenshot of your last text about your partner"
**`text_answer`** - Written response prompt
- ALWAYS symmetric (partner_text = NULL, no inverse needed)
- Partners type a written response
- `config`: `{"max_length": 500}` (default 500)
- Great for: reflections, love notes, confessions, creative writing, finish-the-sentence prompts
- Writing can feel safer than speaking for vulnerable topics
- Format: prompts that invite a thoughtful written response
- Examples:
- "Describe your partner in three words"
- "Write a short love letter to your partner"
- "Finish this sentence: When I'm with you I feel..."
#### 3.4 Type Distribution Guidelines #### 3.4 Type Distribution Guidelines
For a standard 30-question pack, aim for roughly: For a standard 30-question pack, aim for roughly:
- **20-22 swipe** questions (the core experience) - **18-20 swipe** questions (the core experience)
- **4-5 who_likely** questions - **4-5 who_likely** questions
- **2-3 audio** questions - **2-3 audio** questions
- **2-3 photo** questions - **2-3 photo** questions
- **2-3 text_answer** questions
Adjust based on what fits the pack's theme. Some packs may lean heavier on certain types. Adjust based on what fits the pack's theme. Some packs may lean heavier on certain types.
@@ -147,7 +160,7 @@ Adjust based on what fits the pack's theme. Some packs may lean heavier on certa
#### 3.6 Critical: Inverse Questions and Database Linking (swipe questions only) #### 3.6 Critical: Inverse Questions and Database Linking (swipe questions only)
Non-swipe types (who_likely, audio, photo) are ALWAYS symmetric — no inverse needed. Non-swipe types (who_likely, audio, photo, text_answer) are ALWAYS symmetric — no inverse needed.
For EVERY asymmetric **swipe** question, create its inverse AND link them using the `inverse_of` column: For EVERY asymmetric **swipe** question, create its inverse AND link them using the `inverse_of` column:
@@ -225,7 +238,7 @@ Before outputting, verify each question against this checklist:
- [ ] who_likely questions use "Who is more likely to..." format - [ ] who_likely questions use "Who is more likely to..." format
- [ ] audio questions prompt a natural spoken response - [ ] audio questions prompt a natural spoken response
- [ ] photo questions prompt something visual and shareable - [ ] photo questions prompt something visual and shareable
- [ ] Non-swipe types have partner_text = NULL and no inverse - [ ] Non-swipe types (who_likely, audio, photo, text_answer) have partner_text = NULL and no inverse
- [ ] Type distribution is balanced (see 3.4) - [ ] Type distribution is balanced (see 3.4)
**Structure:** **Structure:**
@@ -291,6 +304,11 @@ INSERT INTO questions (pack_id, text, partner_text, intensity, question_type, co
-- photo questions (always symmetric, no inverse) -- photo questions (always symmetric, no inverse)
INSERT INTO questions (pack_id, text, partner_text, intensity, question_type, config, inverse_of) VALUES 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); ('[pack_id]', 'Share a photo that makes you think of your partner', NULL, 1, 'photo', '{}', NULL);
-- text_answer questions (always symmetric, no inverse)
INSERT INTO questions (pack_id, text, partner_text, intensity, question_type, config, inverse_of) VALUES
('[pack_id]', 'Describe your partner in three words', NULL, 1, 'text_answer', '{"max_length": 500}', NULL),
('[pack_id]', 'Write a short love letter to your partner', NULL, 1, 'text_answer', '{"max_length": 500}', NULL);
``` ```
Also provide a summary table: Also provide a summary table:
@@ -303,6 +321,7 @@ Also provide a summary table:
| who_likely questions | X | | who_likely questions | X |
| audio questions | X | | audio questions | X |
| photo questions | X | | photo questions | X |
| text_answer questions | X |
| Symmetric (null partner_text) | X | | Symmetric (null partner_text) | X |
| Asymmetric swipe pairs (with inverse_of links) | X pairs (X questions) | | Asymmetric swipe pairs (with inverse_of links) | X pairs (X questions) |
| With couple targeting | X | | With couple targeting | X |
@@ -329,7 +348,7 @@ Also provide a summary table:
- DON'T use cheesy or cliche language - DON'T use cheesy or cliche language
- DON'T assume heterosexual couples - DON'T assume heterosexual couples
- DON'T skip the inverse question requirement for asymmetric swipe questions - 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 create inverses for who_likely, audio, photo, or text_answer questions (they're always symmetric)
- DON'T forget to set question_type - every question needs one - DON'T forget to set question_type - every question needs one
- DON'T use interview-style questions ("Would you like to...") - DON'T use interview-style questions ("Would you like to...")
- DON'T treat inverse pairs as duplicates - they are REQUIRED (one's `text` = other's `partner_text`) - DON'T treat inverse pairs as duplicates - they are REQUIRED (one's `text` = other's `partner_text`)
+29
View File
@@ -204,6 +204,35 @@ This approach:
- **Profile creation trigger**: The `on_auth_user_created` trigger automatically creates a profile when a user signs up. Never assume a profile exists without checking. - **Profile creation trigger**: The `on_auth_user_created` trigger automatically creates a profile when a user signs up. Never assume a profile exists without checking.
- **Check for null profiles** in edge functions before performing operations. - **Check for null profiles** in edge functions before performing operations.
### Cron Jobs (pg_cron) - IMPORTANT
**NEVER hardcode Supabase URLs in cron job migrations.** Migrations run on both prod and non-prod, so a hardcoded production URL will cause non-prod cron jobs to hit production (double-firing notifications, duplicate processing, etc.).
**Correct pattern** — use `get_supabase_edge_function_url()`:
```sql
SELECT cron.schedule(
'my-cron-job',
'*/5 * * * *',
format(
$cmd$
SELECT net.http_post(
url := %L,
headers := jsonb_build_object('Content-Type', 'application/json'),
body := '{}'::jsonb
)
$cmd$,
get_supabase_edge_function_url('my-edge-function')
)
);
```
This resolves the URL from `app_config.supabase_url` at migration-time, so each environment targets its own edge functions.
**If adding a new environment**, you must seed the URL before running migrations:
```sql
UPDATE app_config SET supabase_url = 'https://<project-ref>.supabase.co';
```
### Key Patterns ### Key Patterns
- Responses use UPSERT with `onConflict: "user_id,question_id"` - Responses use UPSERT with `onConflict: "user_id,question_id"`
- Match detection happens in `submit-response` edge function after each response - Match detection happens in `submit-response` edge function after each response
+33 -8
View File
@@ -79,13 +79,27 @@ Partners take or share a photo. Great for selfies, throwbacks, creative challeng
- "Share a screenshot of your last text about your partner" - "Share a screenshot of your last text about your partner"
- "Show the view from where you are right now" - "Show the view from where you are right now"
### `text_answer` - Written Response Prompt
Partners type a written response. Great for reflections, love notes, confessions, creative writing, finish-the-sentence prompts.
- **ALWAYS symmetric** — partner_text = NULL, no inverse needed
- `config`: `{"max_length": 500}`
- Writing can feel safer than speaking for vulnerable topics
- Format: prompts that invite a thoughtful written response
- Examples:
- "Describe your partner in three words"
- "Write a short love letter to your partner"
- "Finish this sentence: When I'm with you I feel..."
- "Write down a fantasy you haven't said out loud"
### Type Distribution ### Type Distribution
For a standard 30-question pack, aim for roughly: For a standard 30-question pack, aim for roughly:
- **20-22 swipe** questions (the core experience) - **18-20 swipe** questions (the core experience)
- **4-5 who_likely** questions - **4-5 who_likely** questions
- **2-3 audio** questions - **2-3 audio** questions
- **2-3 photo** questions - **2-3 photo** questions
- **2-3 text_answer** 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. 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.
@@ -97,7 +111,7 @@ Each question has:
- `text` - What Partner A sees - `text` - What Partner A sees
- `partner_text` - What Partner B sees (NULL for symmetric questions and all non-swipe types) - `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) - `intensity` - 1-5 scale (for backend filtering, not shown in UI)
- `question_type` - `swipe`, `who_likely`, `audio`, or `photo` - `question_type` - `swipe`, `who_likely`, `audio`, `photo`, or `text_answer`
- `config` - JSONB for type-specific settings (e.g., `{"max_duration_seconds": 60}` for audio) - `config` - JSONB for type-specific settings (e.g., `{"max_duration_seconds": 60}` for audio)
- `allowed_couple_genders` - Which couple types see this (NULL = all) - `allowed_couple_genders` - Which couple types see this (NULL = all)
- `target_user_genders` - Which gender initiates (NULL = any) - `target_user_genders` - Which gender initiates (NULL = any)
@@ -148,7 +162,7 @@ This ensures:
**IMPORTANT:** **IMPORTANT:**
- Inverse pairs are NOT duplicates. When one question's `text` matches another's `partner_text`, they form a valid inverse pair. This is intentional and required - do not flag or remove these as duplicates. - Inverse pairs are NOT duplicates. When one question's `text` matches another's `partner_text`, they form a valid inverse pair. This is intentional and required - do not flag or remove these as duplicates.
- Non-swipe types (who_likely, audio, photo) NEVER have inverses — they are always symmetric. - Non-swipe types (who_likely, audio, photo, text_answer) NEVER have inverses — they are always symmetric.
--- ---
@@ -167,7 +181,7 @@ Controls which couple types see the question. Options: `male+male`, `female+male
| Requires vagina | `['female+male', 'female+female']` | Exclude M+M | | Requires vagina | `['female+male', 'female+female']` | Exclude M+M |
| Sex toys | NULL | Toys work for everyone | | Sex toys | NULL | Toys work for everyone |
**Note:** who_likely, audio, and photo questions are always NULL (they're gender-neutral by nature). **Note:** who_likely, audio, photo, and text_answer questions are always NULL (they're gender-neutral by nature).
### Initiator Targeting (target_user_genders) ### Initiator Targeting (target_user_genders)
@@ -278,7 +292,7 @@ Advanced/BDSM/extreme exploration
- Use cheesy phrases ("make love", "intimate connection", "souls intertwining") - Use cheesy phrases ("make love", "intimate connection", "souls intertwining")
- Assume heterosexual couples by default - Assume heterosexual couples by default
- Create one-sided asymmetric swipe 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) - Create inverses for who_likely, audio, photo, or text_answer (they're symmetric)
- Use time-specific language - Use time-specific language
- Be preachy or educational in tone - Be preachy or educational in tone
- Include judgment about activities - Include judgment about activities
@@ -309,7 +323,7 @@ When asked to create a pack:
- What question types fit best for this theme? - What question types fit best for this theme?
3. **Generate questions** 3. **Generate questions**
- Mix of swipe, who_likely, audio, and photo types - Mix of swipe, who_likely, audio, photo, and text_answer types
- For swipe: mix of symmetric (null partner_text) and asymmetric questions - For swipe: mix of symmetric (null partner_text) and asymmetric questions
- For EVERY asymmetric swipe question, immediately create its inverse - For EVERY asymmetric swipe question, immediately create its inverse
- Vary sentence structure and openers - Vary sentence structure and openers
@@ -349,7 +363,9 @@ INSERT INTO questions (id, pack_id, text, partner_text, intensity, question_type
-- audio (always symmetric, no inverse) -- 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), (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) -- photo (always symmetric, no inverse)
(gen_random_uuid(), '[pack_id]', 'Share a photo that makes you smile', NULL, 1, 'photo', '{}', NULL, NULL, NULL, NULL); (gen_random_uuid(), '[pack_id]', 'Share a photo that makes you smile', NULL, 1, 'photo', '{}', NULL, NULL, NULL, NULL),
-- text_answer (always symmetric, no inverse)
(gen_random_uuid(), '[pack_id]', 'Describe your partner in three words', NULL, 1, 'text_answer', '{"max_length": 500}', NULL, NULL, NULL, NULL);
``` ```
--- ---
@@ -424,6 +440,15 @@ question_type: photo
config: {} config: {}
``` ```
### text_answer
```
text: "Write a short love letter to your partner"
partner_text: NULL
intensity: 1
question_type: text_answer
config: {"max_length": 500}
```
### Swipe - Steamy (Intensity 4) - With Couple Targeting ### Swipe - Steamy (Intensity 4) - With Couple Targeting
``` ```
text: "Give your partner a blowjob in a risky location" text: "Give your partner a blowjob in a risky location"
@@ -470,7 +495,7 @@ config: {}
- [ ] who_likely uses "Who is more likely to..." format - [ ] who_likely uses "Who is more likely to..." format
- [ ] audio prompts invite a natural spoken response - [ ] audio prompts invite a natural spoken response
- [ ] photo prompts invite a visual response - [ ] photo prompts invite a visual response
- [ ] Non-swipe types have partner_text = NULL and inverse_of = NULL - [ ] Non-swipe types (who_likely, audio, photo, text_answer) have partner_text = NULL and inverse_of = NULL
- [ ] Type distribution is balanced - [ ] Type distribution is balanced
**Structure:** **Structure:**
@@ -0,0 +1,160 @@
-- Store the project's own Supabase URL in app_config so cron jobs can
-- target the correct environment. This prevents non-prod cron jobs from
-- accidentally hitting production edge functions.
ALTER TABLE app_config
ADD COLUMN IF NOT EXISTS supabase_url TEXT;
-- Helper function for building edge function URLs from app_config
CREATE OR REPLACE FUNCTION get_supabase_edge_function_url(function_name TEXT)
RETURNS TEXT
LANGUAGE sql
STABLE
AS $$
SELECT (SELECT supabase_url FROM app_config LIMIT 1)
|| '/functions/v1/'
|| function_name;
$$;
-- Re-register all cron jobs using the helper function.
-- format() resolves at migration-time, baking the URL into the cron command.
-- IMPORTANT: app_config.supabase_url MUST be set before this migration runs.
-- CI should run: UPDATE app_config SET supabase_url = '...' before db push,
-- or it can be seeded manually per environment.
-- For safety, abort if supabase_url is not set
DO $$
BEGIN
IF (SELECT supabase_url FROM app_config LIMIT 1) IS NULL THEN
RAISE EXCEPTION 'app_config.supabase_url must be set before running this migration. '
'Run: UPDATE app_config SET supabase_url = ''https://<project-ref>.supabase.co'';';
END IF;
END;
$$;
SELECT cron.unschedule('send-pack-change-notifications');
SELECT cron.schedule(
'send-pack-change-notifications',
'*/5 * * * *',
format(
$cmd$
SELECT net.http_post(
url := %L,
headers := jsonb_build_object('Content-Type', 'application/json'),
body := '{}'::jsonb
)
$cmd$,
get_supabase_edge_function_url('send-pack-change-notification')
)
);
SELECT cron.unschedule('send-partner-activity-notifications');
SELECT cron.schedule(
'send-partner-activity-notifications',
'*/5 * * * *',
format(
$cmd$
SELECT net.http_post(
url := %L,
headers := jsonb_build_object('Content-Type', 'application/json'),
body := '{}'::jsonb
)
$cmd$,
get_supabase_edge_function_url('send-partner-activity-notification')
)
);
SELECT cron.unschedule('process-scheduled-releases');
SELECT cron.schedule(
'process-scheduled-releases',
'*/5 * * * *',
format(
$cmd$
SELECT net.http_post(
url := %L,
headers := jsonb_build_object('Content-Type', 'application/json'),
body := '{}'::jsonb
)
$cmd$,
get_supabase_edge_function_url('process-scheduled-releases')
)
);
SELECT cron.unschedule('check-streak-milestones');
SELECT cron.schedule(
'check-streak-milestones',
'5 0 * * *',
format(
$cmd$
SELECT net.http_post(
url := %L,
headers := jsonb_build_object('Content-Type', 'application/json'),
body := '{}'::jsonb
)
$cmd$,
get_supabase_edge_function_url('check-streak-milestones')
)
);
SELECT cron.unschedule('send-match-notification-digests');
SELECT cron.schedule(
'send-match-notification-digests',
'* * * * *',
format(
$cmd$
SELECT net.http_post(
url := %L,
headers := jsonb_build_object('Content-Type', 'application/json'),
body := '{}'::jsonb
)
$cmd$,
get_supabase_edge_function_url('send-match-notification-digest')
)
);
SELECT cron.unschedule('send-weekly-summary');
SELECT cron.schedule(
'send-weekly-summary',
'0 10 * * 0',
format(
$cmd$
SELECT net.http_post(
url := %L,
headers := jsonb_build_object('Content-Type', 'application/json'),
body := '{}'::jsonb
)
$cmd$,
get_supabase_edge_function_url('send-weekly-summary')
)
);
SELECT cron.unschedule('send-unpaired-reminders');
SELECT cron.schedule(
'send-unpaired-reminders',
'0 18 * * *',
format(
$cmd$
SELECT net.http_post(
url := %L,
headers := jsonb_build_object('Content-Type', 'application/json'),
body := '{}'::jsonb
)
$cmd$,
get_supabase_edge_function_url('send-unpaired-reminder')
)
);
SELECT cron.unschedule('send-catchup-reminders');
SELECT cron.schedule(
'send-catchup-reminders',
'0 17 * * *',
format(
$cmd$
SELECT net.http_post(
url := %L,
headers := jsonb_build_object('Content-Type', 'application/json'),
body := '{}'::jsonb
)
$cmd$,
get_supabase_edge_function_url('send-catchup-reminder')
)
);