From 964fc25fc7579d357227f23f60d9babde56a17fc Mon Sep 17 00:00:00 2001 From: Jamie Brunton Date: Sun, 1 Feb 2026 10:12:30 +0000 Subject: [PATCH] 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. --- .claude/commands/generate-pack.md | 31 +++- CLAUDE.md | 29 ++++ PACK_GENERATION_AGENT.md | 41 ++++- ...01100806_use_dynamic_url_for_cron_jobs.sql | 160 ++++++++++++++++++ 4 files changed, 247 insertions(+), 14 deletions(-) create mode 100644 apps/supabase/migrations/20260201100806_use_dynamic_url_for_cron_jobs.sql diff --git a/.claude/commands/generate-pack.md b/.claude/commands/generate-pack.md index c3c9103..f2895fa 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= [--count=30] [--premium] [--types=swipe,who_likely,audio,photo] +argument-hint: --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`) diff --git a/CLAUDE.md b/CLAUDE.md index 3579a8a..a7f2f2e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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://.supabase.co'; +``` + ### Key Patterns - Responses use UPSERT with `onConflict: "user_id,question_id"` - Match detection happens in `submit-response` edge function after each response diff --git a/PACK_GENERATION_AGENT.md b/PACK_GENERATION_AGENT.md index 94a837c..b934a39 100644 --- a/PACK_GENERATION_AGENT.md +++ b/PACK_GENERATION_AGENT.md @@ -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:** diff --git a/apps/supabase/migrations/20260201100806_use_dynamic_url_for_cron_jobs.sql b/apps/supabase/migrations/20260201100806_use_dynamic_url_for_cron_jobs.sql new file mode 100644 index 0000000..0ea8fe7 --- /dev/null +++ b/apps/supabase/migrations/20260201100806_use_dynamic_url_for_cron_jobs.sql @@ -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://.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') + ) +);