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
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
---
@@ -31,7 +31,7 @@ Extract from $ARGUMENTS:
- **Category**: Required - which category this belongs to (or "new" to create)
- **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`
- **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
@@ -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"
- "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
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
- **2-3 audio** 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.
@@ -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)
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:
@@ -225,7 +238,7 @@ Before outputting, verify each question against this checklist:
- [ ] 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
- [ ] Non-swipe types (who_likely, audio, photo, text_answer) have partner_text = NULL and no inverse
- [ ] Type distribution is balanced (see 3.4)
**Structure:**
@@ -291,6 +304,11 @@ INSERT INTO questions (pack_id, text, partner_text, intensity, question_type, co
-- 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);
-- 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:
@@ -303,6 +321,7 @@ Also provide a summary table:
| who_likely questions | X |
| audio questions | X |
| photo questions | X |
| text_answer questions | X |
| Symmetric (null partner_text) | X |
| Asymmetric swipe pairs (with inverse_of links) | X pairs (X questions) |
| With couple targeting | X |
@@ -329,7 +348,7 @@ Also provide a summary table:
- DON'T use cheesy or cliche language
- DON'T assume heterosexual couples
- 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 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`)
+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.
- **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
- Responses use UPSERT with `onConflict: "user_id,question_id"`
- 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"
- "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
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
- **2-3 audio** 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.
@@ -97,7 +111,7 @@ Each question has:
- `text` - What Partner A sees
- `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`
- `question_type` - `swipe`, `who_likely`, `audio`, `photo`, or `text_answer`
- `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)
@@ -148,7 +162,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.
- 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 |
| 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)
@@ -278,7 +292,7 @@ Advanced/BDSM/extreme exploration
- Use cheesy phrases ("make love", "intimate connection", "souls intertwining")
- Assume heterosexual couples by default
- 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
- Be preachy or educational in tone
- Include judgment about activities
@@ -309,7 +323,7 @@ When asked to create a pack:
- What question types fit best for this theme?
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 EVERY asymmetric swipe question, immediately create its inverse
- 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)
(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);
(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: {}
```
### 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
```
text: "Give your partner a blowjob in a risky location"
@@ -470,7 +495,7 @@ config: {}
- [ ] 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
- [ ] Non-swipe types (who_likely, audio, photo, text_answer) have partner_text = NULL and inverse_of = NULL
- [ ] Type distribution is balanced
**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')
)
);