mirror of
https://github.com/jjbrunton/Sauci.git
synced 2026-11-02 10:19:43 +00:00
Compare commits
9
Commits
e380c0a31c
...
c8aaa8967d
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c8aaa8967d | ||
|
|
8a44fef764 | ||
|
|
edb2cedda0 | ||
|
|
73d7c1a200 | ||
|
|
307af2c1e9 | ||
|
|
057c682c6c | ||
|
|
e29183f104 | ||
|
|
f07f80b529 | ||
|
|
25db338861 |
No files matched your search
@@ -1,6 +1,6 @@
|
||||
---
|
||||
allowed-tools: Read, mcp__sauci-prod__execute_sql, mcp__sauci-prod__list_tables
|
||||
argument-hint: <pack-name> --category=<category> --intensity=<min-max> [--count=30] [--premium]
|
||||
argument-hint: <pack-name> --category=<category> [--count=30] [--premium] [--types=swipe,who_likely,audio,photo]
|
||||
description: Generate a question pack for Sauci following established content guidelines
|
||||
---
|
||||
|
||||
@@ -18,6 +18,7 @@ First, read the pack generation guidelines:
|
||||
- Existing categories: can be retrieved from supabase with query: SELECT name, id FROM categories ORDER BY sort_order
|
||||
- Existing packs in target category: Will query once category is identified
|
||||
- Existing props: can be retrieved with: SELECT DISTINCT unnest(required_props) as prop FROM questions WHERE required_props IS NOT NULL ORDER BY prop
|
||||
- Current question type distribution in target pack (if adding to existing): SELECT question_type, count(*) FROM questions WHERE pack_id = '[pack_id]' AND deleted_at IS NULL GROUP BY question_type
|
||||
|
||||
## Task
|
||||
|
||||
@@ -28,26 +29,28 @@ Generate a complete question pack following all Sauci content guidelines.
|
||||
Extract from $ARGUMENTS:
|
||||
- **Pack name**: Required - the name for this pack
|
||||
- **Category**: Required - which category this belongs to (or "new" to create)
|
||||
- **Intensity range**: Required - e.g., "2-3", "3-4", "4-5"
|
||||
- **Question count**: Optional - default 30, can be 25-50
|
||||
- **Premium**: Optional flag - if pack should be premium
|
||||
- **Types**: Optional - comma-separated question types to generate. Default: `swipe,who_likely,audio,photo`. Options: `swipe`, `who_likely`, `audio`, `photo`
|
||||
|
||||
### Step 2: Understand Context
|
||||
|
||||
1. If category exists, query existing packs to understand progression:
|
||||
1. If category exists, query existing packs to understand what's there:
|
||||
- What packs already exist?
|
||||
- What intensity levels are covered?
|
||||
- Where does this new pack fit in the progression?
|
||||
- What question types are represented?
|
||||
- Where does this new pack fit?
|
||||
|
||||
2. Check if category is explicit:
|
||||
```sql
|
||||
SELECT id, name, is_explicit FROM categories WHERE name ILIKE '%<category>%';
|
||||
```
|
||||
|
||||
3. If new category, establish the progression plan:
|
||||
- What will the beginner pack cover?
|
||||
- What will intermediate cover?
|
||||
- What will advanced cover?
|
||||
3. Check the current type distribution if adding to an existing pack:
|
||||
```sql
|
||||
SELECT question_type, count(*) FROM questions
|
||||
WHERE pack_id = '[pack_id]' AND deleted_at IS NULL
|
||||
GROUP BY question_type ORDER BY count DESC;
|
||||
```
|
||||
|
||||
### Step 3: Generate Questions
|
||||
|
||||
@@ -74,9 +77,60 @@ Follow these rules strictly:
|
||||
**For NON-EXPLICIT categories (is_explicit = false):**
|
||||
- CRITICAL: No sexual acts, crude language, or NSFW content
|
||||
- Keep language romantic, sensual, or playful without graphic terms
|
||||
- If intensity 3+ activities are needed, use suggestive but not explicit language
|
||||
|
||||
#### 3.3 Partner Text Rules
|
||||
#### 3.3 Question Types
|
||||
|
||||
Each question MUST have a `question_type`. The default mix should include variety across types.
|
||||
|
||||
**`swipe`** (default) - Standard yes/no/maybe proposal
|
||||
- The classic Sauci format
|
||||
- Can be symmetric or asymmetric (with partner_text + inverse)
|
||||
- Example: "Give your partner a back massage"
|
||||
|
||||
**`who_likely`** - "Who's more likely to..." format
|
||||
- ALWAYS symmetric (partner_text = NULL, no inverse needed)
|
||||
- Both partners pick who they think is more likely
|
||||
- Match created when both answer
|
||||
- Format: "Who is more likely to [activity/trait]?"
|
||||
- Keep fun and lighthearted — these are conversation starters
|
||||
- Examples:
|
||||
- "Who is more likely to cry during a movie?"
|
||||
- "Who is more likely to plan a surprise date?"
|
||||
- "Who is more likely to fall asleep first?"
|
||||
|
||||
**`audio`** - Voice recording prompt
|
||||
- ALWAYS symmetric (partner_text = NULL, no inverse needed)
|
||||
- Partners record a voice message in response
|
||||
- `config`: `{"max_duration_seconds": 60}` (default 60)
|
||||
- Great for: confessions, storytelling, impressions, compliments, singing
|
||||
- Format: prompts that invite a spoken response
|
||||
- Examples:
|
||||
- "Record your best impression of your partner"
|
||||
- "Describe your favourite memory together"
|
||||
- "Say something you've never told your partner"
|
||||
|
||||
**`photo`** - Photo prompt
|
||||
- ALWAYS symmetric (partner_text = NULL, no inverse needed)
|
||||
- Partners take or share a photo in response
|
||||
- No special config needed
|
||||
- Great for: selfies, throwbacks, showing something, creative challenges
|
||||
- Format: prompts that invite a photo response
|
||||
- Examples:
|
||||
- "Share a photo from your camera roll that makes you think of your partner"
|
||||
- "Take a selfie showing how you feel right now"
|
||||
- "Share a screenshot of your last text about your partner"
|
||||
|
||||
#### 3.4 Type Distribution Guidelines
|
||||
|
||||
For a standard 30-question pack, aim for roughly:
|
||||
- **20-22 swipe** questions (the core experience)
|
||||
- **4-5 who_likely** questions
|
||||
- **2-3 audio** questions
|
||||
- **2-3 photo** questions
|
||||
|
||||
Adjust based on what fits the pack's theme. Some packs may lean heavier on certain types.
|
||||
|
||||
#### 3.5 Partner Text Rules (swipe questions only)
|
||||
|
||||
- **NULL** for symmetric activities (both partners do the same thing together)
|
||||
- **Filled** for asymmetric activities (one does something to/for the other)
|
||||
@@ -91,9 +145,11 @@ Follow these rules strictly:
|
||||
- BAD: "Receive oral from your partner" (clinical)
|
||||
- GOOD: "Let your partner pleasure you with their mouth" (enticing)
|
||||
|
||||
#### 3.4 Critical: Inverse Questions and Database Linking
|
||||
#### 3.6 Critical: Inverse Questions and Database Linking (swipe questions only)
|
||||
|
||||
For EVERY asymmetric question, create its inverse AND link them using the `inverse_of` column:
|
||||
Non-swipe types (who_likely, audio, photo) are ALWAYS symmetric — no inverse needed.
|
||||
|
||||
For EVERY asymmetric **swipe** question, create its inverse AND link them using the `inverse_of` column:
|
||||
|
||||
```
|
||||
Original (PRIMARY - inverse_of = NULL):
|
||||
@@ -114,12 +170,9 @@ This ensures:
|
||||
2. The database tracks which questions are inverses of each other
|
||||
3. Accurate unique question counts (200 questions = 100 unique concepts with inverses)
|
||||
|
||||
**Database Schema:**
|
||||
- `inverse_of` column: UUID referencing the primary question's ID
|
||||
- Primary questions have `inverse_of = NULL`
|
||||
- Inverse questions have `inverse_of = <primary_question_id>`
|
||||
#### 3.7 Intensity Guidelines
|
||||
|
||||
#### 3.5 Intensity Guidelines
|
||||
Each question still needs an `intensity` value (1-5) for backend filtering, even though it's not shown in the UI.
|
||||
|
||||
| Level | Name | Description | Examples |
|
||||
|-------|------|-------------|----------|
|
||||
@@ -129,7 +182,7 @@ This ensures:
|
||||
| 4 | Steamy | Explicit sex, moderate adventure | intercourse, light bondage, anal play |
|
||||
| 5 | Intense | Advanced/BDSM/extreme | impact play, power dynamics, taboo kinks |
|
||||
|
||||
#### 3.6 Targeting Rules
|
||||
#### 3.8 Targeting Rules
|
||||
|
||||
**Couple Targeting (allowed_couple_genders):**
|
||||
- DEFAULT: null (ALL couples) unless explicit anatomical requirement
|
||||
@@ -140,19 +193,13 @@ This ensures:
|
||||
|
||||
**Initiator Targeting (target_user_genders):**
|
||||
- DEFAULT: null (anyone can initiate)
|
||||
- Only for asymmetric questions (those with partner_text)
|
||||
- Set based on who does the action in "text" field:
|
||||
- "Swallow your partner's cum" -> partner has penis -> initiator: `['female']` in M+F couples
|
||||
- "Deep throat your partner" -> partner has penis -> `['female']` in M+F couples
|
||||
- "Give your partner a massage" -> gender-neutral -> null
|
||||
- Only for asymmetric swipe questions (those with partner_text)
|
||||
- Set based on who does the action in "text" field
|
||||
- When in doubt, use null
|
||||
|
||||
**Anatomical Consistency:**
|
||||
- NEVER mix male-specific and female-specific acts as alternatives
|
||||
- BAD: "Finger or give your partner a handjob" (mixed anatomy)
|
||||
- GOOD: Pick one activity per question
|
||||
**Note:** who_likely, audio, and photo questions are always NULL for both targeting fields (they're symmetric and gender-neutral).
|
||||
|
||||
#### 3.7 Required Props
|
||||
#### 3.9 Required Props
|
||||
|
||||
Identify physical items/accessories needed:
|
||||
- Props are items like: blindfold, remote vibrator, handcuffs, massage oil, ice cubes
|
||||
@@ -167,15 +214,22 @@ Before outputting, verify each question against this checklist:
|
||||
|
||||
**Language & Format:**
|
||||
- [ ] Uses "your partner" (no gendered pronouns)
|
||||
- [ ] Is a proposal, not an interview question
|
||||
- [ ] Swipe questions are proposals, not interview questions
|
||||
- [ ] No wishy-washy language
|
||||
- [ ] No time-specific words
|
||||
- [ ] 5-12 words (concise)
|
||||
- [ ] No cheesy/cliche language
|
||||
- [ ] Varied sentence structures
|
||||
|
||||
**Question Types:**
|
||||
- [ ] who_likely questions use "Who is more likely to..." format
|
||||
- [ ] audio questions prompt a natural spoken response
|
||||
- [ ] photo questions prompt something visual and shareable
|
||||
- [ ] Non-swipe types have partner_text = NULL and no inverse
|
||||
- [ ] Type distribution is balanced (see 3.4)
|
||||
|
||||
**Structure:**
|
||||
- [ ] All asymmetric questions have inverses (these are NOT duplicates - they're required pairs)
|
||||
- [ ] All asymmetric swipe questions have inverses (these are NOT duplicates - they're required pairs)
|
||||
- [ ] Partner text is appealing (not clinical)
|
||||
- [ ] Partner text frames responses as "allowing" not forced
|
||||
- [ ] No actual duplicates (same `text` appearing twice)
|
||||
@@ -190,7 +244,7 @@ Before outputting, verify each question against this checklist:
|
||||
**Consistency:**
|
||||
- [ ] No mixed anatomy in alternatives
|
||||
- [ ] Tone matches explicit/non-explicit category
|
||||
- [ ] Fits progression within category
|
||||
- [ ] Fits within the category
|
||||
|
||||
### Step 5: Output
|
||||
|
||||
@@ -207,55 +261,50 @@ VALUES (
|
||||
)
|
||||
RETURNING id;
|
||||
|
||||
-- Then insert questions with inverse_of linking
|
||||
-- For inverse pairs: insert primary first (inverse_of = NULL), then inverse pointing to primary
|
||||
-- Then insert questions
|
||||
-- For swipe inverse pairs: insert primary first (inverse_of = NULL), then inverse pointing to primary
|
||||
|
||||
-- Generate UUIDs for inverse pairs upfront
|
||||
-- Pair 1: Primary and its inverse
|
||||
-- Swipe pair example
|
||||
WITH pair1_primary AS (
|
||||
INSERT INTO questions (pack_id, text, partner_text, intensity, inverse_of)
|
||||
VALUES ('[pack_id]', 'Spank your partner', 'Be spanked by your partner', 3, NULL)
|
||||
INSERT INTO questions (pack_id, text, partner_text, intensity, question_type, config, inverse_of)
|
||||
VALUES ('[pack_id]', 'Spank your partner', 'Be spanked by your partner', 3, 'swipe', '{}', NULL)
|
||||
RETURNING id
|
||||
)
|
||||
INSERT INTO questions (pack_id, text, partner_text, intensity, inverse_of)
|
||||
SELECT '[pack_id]', 'Be spanked by your partner', 'Spank your partner', 3, id
|
||||
INSERT INTO questions (pack_id, text, partner_text, intensity, question_type, config, inverse_of)
|
||||
SELECT '[pack_id]', 'Be spanked by your partner', 'Spank your partner', 3, 'swipe', '{}', id
|
||||
FROM pair1_primary;
|
||||
|
||||
-- Symmetric questions (no inverse_of needed)
|
||||
INSERT INTO questions (pack_id, text, partner_text, intensity, allowed_couple_genders, target_user_genders, required_props, inverse_of) VALUES
|
||||
('[pack_id]', 'Symmetric question text', NULL, 2, NULL, NULL, NULL, NULL);
|
||||
-- Symmetric swipe (no inverse needed)
|
||||
INSERT INTO questions (pack_id, text, partner_text, intensity, question_type, config, inverse_of) VALUES
|
||||
('[pack_id]', 'Cook dinner together', NULL, 1, 'swipe', '{}', NULL);
|
||||
|
||||
-- Or bulk insert with explicit UUIDs for pairs:
|
||||
INSERT INTO questions (id, pack_id, text, partner_text, intensity, inverse_of) VALUES
|
||||
-- Pair 1
|
||||
('uuid-primary-1', '[pack_id]', 'Give your partner a massage', 'Receive a massage from your partner', 2, NULL),
|
||||
('uuid-inverse-1', '[pack_id]', 'Receive a massage from your partner', 'Give your partner a massage', 2, 'uuid-primary-1'),
|
||||
-- Pair 2
|
||||
('uuid-primary-2', '[pack_id]', 'Blindfold your partner', 'Be blindfolded by your partner', 3, NULL),
|
||||
('uuid-inverse-2', '[pack_id]', 'Be blindfolded by your partner', 'Blindfold your partner', 3, 'uuid-primary-2'),
|
||||
-- Symmetric (no pair)
|
||||
(gen_random_uuid(), '[pack_id]', 'Cook dinner together', NULL, 1, NULL);
|
||||
-- who_likely questions (always symmetric, no inverse)
|
||||
INSERT INTO questions (pack_id, text, partner_text, intensity, question_type, config, inverse_of) VALUES
|
||||
('[pack_id]', 'Who is more likely to plan a surprise date?', NULL, 1, 'who_likely', '{}', NULL),
|
||||
('[pack_id]', 'Who is more likely to cry during a movie?', NULL, 1, 'who_likely', '{}', NULL);
|
||||
|
||||
-- audio questions (always symmetric, no inverse)
|
||||
INSERT INTO questions (pack_id, text, partner_text, intensity, question_type, config, inverse_of) VALUES
|
||||
('[pack_id]', 'Describe your favourite memory together', NULL, 1, 'audio', '{"max_duration_seconds": 60}', NULL),
|
||||
('[pack_id]', 'Record your best impression of your partner', NULL, 1, 'audio', '{"max_duration_seconds": 60}', NULL);
|
||||
|
||||
-- photo questions (always symmetric, no inverse)
|
||||
INSERT INTO questions (pack_id, text, partner_text, intensity, question_type, config, inverse_of) VALUES
|
||||
('[pack_id]', 'Share a photo that makes you think of your partner', NULL, 1, 'photo', '{}', NULL);
|
||||
```
|
||||
|
||||
**Field notes:**
|
||||
- `inverse_of`: NULL for primary/symmetric questions, UUID of primary question for inverses
|
||||
- `allowed_couple_genders`: NULL for all couples, or array like `ARRAY['male+male', 'female+male']`
|
||||
- `target_user_genders`: NULL for any initiator, or array like `ARRAY['female']`
|
||||
- `required_props`: NULL if no props needed, or array like `ARRAY['blindfold', 'ice cubes']`
|
||||
|
||||
Also provide a summary table:
|
||||
|
||||
| Metric | Value |
|
||||
|--------|-------|
|
||||
| Total questions | X |
|
||||
| Unique question concepts | X (total - inverse count) |
|
||||
| Intensity 1 | X |
|
||||
| Intensity 2 | X |
|
||||
| Intensity 3 | X |
|
||||
| Intensity 4 | X |
|
||||
| Intensity 5 | X |
|
||||
| Swipe questions | X |
|
||||
| who_likely questions | X |
|
||||
| audio questions | X |
|
||||
| photo questions | X |
|
||||
| Symmetric (null partner_text) | X |
|
||||
| Asymmetric pairs (with inverse_of links) | X pairs (X questions) |
|
||||
| Asymmetric swipe pairs (with inverse_of links) | X pairs (X questions) |
|
||||
| With couple targeting | X |
|
||||
| With initiator targeting | X |
|
||||
| With required props | X |
|
||||
@@ -263,21 +312,24 @@ Also provide a summary table:
|
||||
## Example Usage
|
||||
|
||||
```
|
||||
/generate-pack "Testing the Waters" --category="Public Thrills" --intensity=2-3 --count=30
|
||||
/generate-pack "Kink Discovery" --category="The Kink Lab" --intensity=3-5 --count=50
|
||||
/generate-pack "Staying Close" --category=new:"Long Distance" --intensity=1 --count=30
|
||||
/generate-pack "Testing the Waters" --category="Public Thrills" --count=30
|
||||
/generate-pack "Kink Discovery" --category="The Kink Lab" --count=50 --premium
|
||||
/generate-pack "Staying Close" --category=new:"Long Distance" --count=30
|
||||
/generate-pack "Date Night" --category="Quality Time" --types=who_likely,audio,photo
|
||||
```
|
||||
|
||||
## Guidelines
|
||||
|
||||
- DO ask clarifying questions if the pack concept is unclear
|
||||
- DO check existing packs in the category first
|
||||
- DO create inverse questions for ALL asymmetric content AND link them with inverse_of
|
||||
- DO create inverse questions for ALL asymmetric swipe content AND link them with inverse_of
|
||||
- DO include a mix of question types (swipe, who_likely, audio, photo)
|
||||
- DO vary sentence structure and openers
|
||||
- DO use the inverse_of column to link inverse pairs (inverse points to primary's UUID)
|
||||
- DON'T use cheesy or cliche language
|
||||
- DON'T assume heterosexual couples
|
||||
- DON'T skip the inverse question requirement
|
||||
- DON'T forget to set inverse_of - this is how we track unique question concepts
|
||||
- DON'T skip the inverse question requirement for asymmetric swipe questions
|
||||
- DON'T create inverses for who_likely, audio, or photo questions (they're always symmetric)
|
||||
- DON'T forget to set question_type - every question needs one
|
||||
- DON'T use interview-style questions ("Would you like to...")
|
||||
- DON'T treat inverse pairs as duplicates - they are REQUIRED (one's `text` = other's `partner_text`)
|
||||
@@ -5,3 +5,17 @@
|
||||
- npm run lint
|
||||
- npm run typecheck
|
||||
- npm run build
|
||||
|
||||
## Database Migrations - MANDATORY RULES
|
||||
|
||||
**NEVER use `mcp__sauci-prod__apply_migration` or `mcp__sauci-non-prod__apply_migration`.** These create migration records in the remote database without local files, which breaks CI/CD deployments.
|
||||
|
||||
**NEVER run DDL (`CREATE`, `ALTER`, `DROP`) via `mcp__*__execute_sql`.** This changes the schema with no migration tracking.
|
||||
|
||||
**All schema changes must be local migration files:**
|
||||
1. Run `supabase migration new <name>` to create the file
|
||||
2. Edit the file in `apps/supabase/migrations/`
|
||||
3. Use idempotent SQL (`IF NOT EXISTS`, `CREATE OR REPLACE`)
|
||||
4. Commit the file — CI deploys it automatically
|
||||
|
||||
**`execute_sql` is ONLY for SELECT queries.** No exceptions.
|
||||
@@ -67,36 +67,63 @@ When using Claude Code with MCP tools:
|
||||
- Use `mcp__sauci-non-prod__*` tools for development/staging database operations
|
||||
- **Always verify which environment you're targeting before running destructive operations**
|
||||
|
||||
### Database Migrations - IMPORTANT
|
||||
### Database Migrations - CRITICAL
|
||||
|
||||
**NEVER use MCP tools (`apply_migration`) for database migrations.** Always use the Supabase CLI instead.
|
||||
> **STOP: Read this entire section before making ANY database schema change.**
|
||||
|
||||
**Why:** MCP migrations are tracked in the database but do NOT create local migration files. This means:
|
||||
- Migration history is not committed to git
|
||||
- Other developers won't get the schema changes
|
||||
- `supabase db reset` won't include MCP-applied migrations
|
||||
- Changes cannot be reproduced from scratch
|
||||
#### The Golden Rule
|
||||
|
||||
**ALL schema changes MUST go through local migration files.** No exceptions.
|
||||
|
||||
#### Forbidden Actions
|
||||
|
||||
- **NEVER call `apply_migration` via MCP** (either `mcp__sauci-prod__apply_migration` or `mcp__sauci-non-prod__apply_migration`). This creates migration entries in the remote database with NO local file, which breaks `supabase db push` in CI and causes deployment failures that are painful to fix.
|
||||
- **NEVER run DDL statements (`CREATE`, `ALTER`, `DROP`) via `execute_sql`**. This changes the schema without any migration tracking at all.
|
||||
- **NEVER manually INSERT into `supabase_migrations.schema_migrations`** unless repairing a broken migration history (and only with explicit user approval).
|
||||
|
||||
#### Why This Matters
|
||||
|
||||
The CI/CD pipeline runs `supabase db push --include-all` which compares local migration files against the remote `schema_migrations` table. If the remote has migrations that don't exist locally, or local files exist without remote entries, the deployment **will fail** and block all subsequent deployments.
|
||||
|
||||
**A single `apply_migration` call can break deployments for the entire project.**
|
||||
|
||||
#### Correct Workflow for Schema Changes
|
||||
|
||||
**Correct workflow for schema changes:**
|
||||
```bash
|
||||
# 1. Create migration file locally
|
||||
supabase migration new <descriptive_name>
|
||||
|
||||
# 2. Edit the migration file in apps/supabase/migrations/
|
||||
|
||||
# 3. Test locally
|
||||
# 3. Make migrations idempotent where possible
|
||||
# Use: CREATE OR REPLACE, IF NOT EXISTS, DROP IF EXISTS
|
||||
# This prevents failures if re-run
|
||||
|
||||
# 4. Test locally
|
||||
supabase db reset
|
||||
|
||||
# 4. Deploy to remote (link to correct project first)
|
||||
# 5. Commit the migration file to git
|
||||
|
||||
# 6. Deploy happens automatically via CI on push to master
|
||||
# OR deploy manually:
|
||||
supabase link --project-ref <project_ref>
|
||||
supabase db push
|
||||
```
|
||||
|
||||
**MCP tools ARE appropriate for:**
|
||||
- Reading data (`execute_sql` for SELECT queries)
|
||||
- Exploring schema (`list_tables`, `list_migrations`)
|
||||
- Deploying edge functions (`deploy_edge_function`)
|
||||
- Generating types (`generate_typescript_types`)
|
||||
#### MCP Tools - What IS Allowed
|
||||
|
||||
- **Reading data:** `execute_sql` with SELECT queries only
|
||||
- **Exploring schema:** `list_tables`, `list_migrations`, `list_extensions`
|
||||
- **Deploying edge functions:** `deploy_edge_function`
|
||||
- **Generating types:** `generate_typescript_types`
|
||||
- **Checking advisors:** `get_advisors`
|
||||
|
||||
#### If You Accidentally Used `apply_migration`
|
||||
|
||||
If a migration was applied via MCP, you must immediately:
|
||||
1. Retrieve the migration SQL from `schema_migrations.statements`
|
||||
2. Create a matching local file: `apps/supabase/migrations/<version>_<name>.sql`
|
||||
3. Commit and push the file so CI can reconcile
|
||||
|
||||
## Architecture
|
||||
|
||||
@@ -135,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)
|
||||
@@ -167,6 +196,8 @@ This approach:
|
||||
| `send-notification` | false | Internal trigger (no auth) |
|
||||
| `send-message-notification` | false | Internal trigger (no auth) |
|
||||
| `send-nudge-notification` | false | Manual via getUser() |
|
||||
| `send-unpaired-reminder` | false | Cron trigger (no auth) |
|
||||
| `send-catchup-reminder` | false | Cron trigger (no auth) |
|
||||
|
||||
### Database Best Practices
|
||||
- **Always use `.maybeSingle()` instead of `.single()`** when the row might not exist. `.single()` throws an error if 0 or >1 rows are returned.
|
||||
|
||||
+168
-127
@@ -33,18 +33,78 @@ You are a content generation specialist for Sauci, a couples intimacy app where
|
||||
|
||||
---
|
||||
|
||||
## Question Types
|
||||
|
||||
Every question has a `question_type` that determines how partners interact with it.
|
||||
|
||||
### `swipe` (default) - Yes/No/Maybe Proposal
|
||||
The classic Sauci format. Partners swipe yes, no, or maybe. Matches are created when both answer positively.
|
||||
|
||||
- Can be **symmetric** (partner_text = NULL) or **asymmetric** (with partner_text + inverse pair)
|
||||
- Example: "Give your partner a back massage"
|
||||
|
||||
### `who_likely` - "Who's More Likely To..."
|
||||
Partners each pick who they think is more likely. Fun, lighthearted conversation starters.
|
||||
|
||||
- **ALWAYS symmetric** — partner_text = NULL, no inverse needed
|
||||
- Format: "Who is more likely to [activity/trait]?"
|
||||
- Match created when both answer
|
||||
- Examples:
|
||||
- "Who is more likely to cry during a movie?"
|
||||
- "Who is more likely to plan a surprise date?"
|
||||
- "Who is more likely to fall asleep first?"
|
||||
- "Who is more likely to forget an anniversary?"
|
||||
|
||||
### `audio` - Voice Recording Prompt
|
||||
Partners record a voice message in response to a prompt. Great for confessions, storytelling, impressions, compliments.
|
||||
|
||||
- **ALWAYS symmetric** — partner_text = NULL, no inverse needed
|
||||
- `config`: `{"max_duration_seconds": 60}`
|
||||
- Format: prompts that invite a natural spoken response
|
||||
- Examples:
|
||||
- "Record your best impression of your partner"
|
||||
- "Describe your favourite memory together"
|
||||
- "Say something you've never told your partner"
|
||||
- "Sing a song that reminds you of your partner"
|
||||
|
||||
### `photo` - Photo Prompt
|
||||
Partners take or share a photo. Great for selfies, throwbacks, creative challenges.
|
||||
|
||||
- **ALWAYS symmetric** — partner_text = NULL, no inverse needed
|
||||
- No special config needed
|
||||
- Format: prompts that invite a visual response
|
||||
- Examples:
|
||||
- "Share a photo from your camera roll that makes you think of your partner"
|
||||
- "Take a selfie showing how you feel right now"
|
||||
- "Share a screenshot of your last text about your partner"
|
||||
- "Show the view from where you are right now"
|
||||
|
||||
### Type Distribution
|
||||
|
||||
For a standard 30-question pack, aim for roughly:
|
||||
- **20-22 swipe** questions (the core experience)
|
||||
- **4-5 who_likely** questions
|
||||
- **2-3 audio** questions
|
||||
- **2-3 photo** questions
|
||||
|
||||
Adjust based on the pack's theme. A "Getting to Know You" pack might lean heavier on who_likely and audio. An explicit pack might be mostly swipe.
|
||||
|
||||
---
|
||||
|
||||
## Question Structure
|
||||
|
||||
Each question has:
|
||||
- `text` - What Partner A sees
|
||||
- `partner_text` - What Partner B sees (can be NULL)
|
||||
- `intensity` - 1-5 scale
|
||||
- `partner_text` - What Partner B sees (NULL for symmetric questions and all non-swipe types)
|
||||
- `intensity` - 1-5 scale (for backend filtering, not shown in UI)
|
||||
- `question_type` - `swipe`, `who_likely`, `audio`, or `photo`
|
||||
- `config` - JSONB for type-specific settings (e.g., `{"max_duration_seconds": 60}` for audio)
|
||||
- `allowed_couple_genders` - Which couple types see this (NULL = all)
|
||||
- `target_user_genders` - Which gender initiates (NULL = any)
|
||||
- `required_props` - Physical items needed (NULL = none)
|
||||
- `inverse_of` - UUID of primary question if this is an inverse (NULL for primary/symmetric)
|
||||
- `inverse_of` - UUID of primary question if this is an inverse (NULL for primary/symmetric/non-swipe)
|
||||
|
||||
### When to use partner_text
|
||||
### When to use partner_text (swipe questions only)
|
||||
|
||||
**Use NULL** for symmetric activities where both partners do the same thing:
|
||||
```
|
||||
@@ -58,9 +118,11 @@ text: "Give your partner a massage"
|
||||
partner_text: "Receive a massage from your partner"
|
||||
```
|
||||
|
||||
### Critical: Inverse Questions and Database Linking
|
||||
**Non-swipe types (who_likely, audio, photo) ALWAYS have partner_text = NULL.**
|
||||
|
||||
For any asymmetric question, you MUST create TWO questions AND link them using the `inverse_of` column:
|
||||
### Critical: Inverse Questions and Database Linking (swipe only)
|
||||
|
||||
For any asymmetric **swipe** question, you MUST create TWO questions AND link them using the `inverse_of` column:
|
||||
|
||||
**Question 1 (PRIMARY - inverse_of = NULL):**
|
||||
```
|
||||
@@ -86,8 +148,7 @@ This ensures:
|
||||
|
||||
**IMPORTANT:**
|
||||
- Inverse pairs are NOT duplicates. When one question's `text` matches another's `partner_text`, they form a valid inverse pair. This is intentional and required - do not flag or remove these as duplicates.
|
||||
- The `inverse_of` column links the pair: the inverse question points to the primary question's UUID
|
||||
- This allows the app to display accurate unique question counts (100 concepts, not 200 questions)
|
||||
- Non-swipe types (who_likely, audio, photo) NEVER have inverses — they are always symmetric.
|
||||
|
||||
---
|
||||
|
||||
@@ -106,22 +167,17 @@ Controls which couple types see the question. Options: `male+male`, `female+male
|
||||
| Requires vagina | `['female+male', 'female+female']` | Exclude M+M |
|
||||
| Sex toys | NULL | Toys work for everyone |
|
||||
|
||||
Examples:
|
||||
- "Give your partner a massage" → NULL
|
||||
- "Give your partner a blowjob" → `['male+male', 'female+male']`
|
||||
- "Go down on your partner" → NULL (oral is possible for all)
|
||||
- "Use a vibrator on your partner" → NULL
|
||||
**Note:** who_likely, audio, and photo questions are always NULL (they're gender-neutral by nature).
|
||||
|
||||
### Initiator Targeting (target_user_genders)
|
||||
|
||||
For asymmetric questions, controls who does the action in `text`.
|
||||
For asymmetric swipe questions, controls who does the action in `text`.
|
||||
|
||||
**DEFAULT: NULL** (anyone can initiate).
|
||||
|
||||
Only set when the `text` field requires specific anatomy:
|
||||
- "Swallow your partner's cum" → partner has penis → `['female']` in M+F
|
||||
- "Deep throat your partner" → partner has penis → `['female']` in M+F
|
||||
- "Let your partner cum inside you" → receiver has vagina → `['female']`
|
||||
|
||||
When in doubt, use NULL.
|
||||
|
||||
@@ -154,6 +210,8 @@ Identify physical items/accessories needed for the activity.
|
||||
|
||||
## Intensity Levels
|
||||
|
||||
Each question needs an `intensity` value (1-5) for backend filtering. This is not shown in the mobile UI but is used to gate content based on user preferences.
|
||||
|
||||
### Level 1 - GENTLE
|
||||
Pure emotional connection, non-sexual bonding
|
||||
- Examples: Cook together, watch movies, take walks, game nights
|
||||
@@ -161,7 +219,7 @@ Pure emotional connection, non-sexual bonding
|
||||
|
||||
### Level 2 - WARM
|
||||
Romantic atmosphere, affectionate touch
|
||||
- Examples: Slow dancing, candlelit dinners, extended kissing, cuddling
|
||||
- Examples: Slow dancing, extended kissing, cuddling
|
||||
- Romantic but not explicit
|
||||
|
||||
### Level 3 - PLAYFUL
|
||||
@@ -181,44 +239,24 @@ Advanced/BDSM/extreme exploration
|
||||
|
||||
---
|
||||
|
||||
## Pack Progression Structure
|
||||
|
||||
Most categories should have 3-5 packs that progress in intensity:
|
||||
|
||||
### Example: Toy Time
|
||||
1. **Starting Out** (intensity 3-4) - Basic vibrators, blindfolds, simple toys
|
||||
2. **Leveling Up Together** (intensity 4, some 5) - Remote toys, edging, combinations
|
||||
3. **The Deep End** (intensity 4-5) - Machines, bondage furniture, advanced play
|
||||
|
||||
### Example: Long Distance
|
||||
1. **Staying Close** (intensity 1) - FaceTime, voice notes, gaming together
|
||||
2. **Missing You** (intensity 2) - Flirty texts, missing each other, anticipation
|
||||
3. **Private Line** (intensity 3-4) - Sexting, phone sex, explicit photos
|
||||
4. **Total Control** (intensity 4-5) - Remote toys, edging, power dynamics
|
||||
|
||||
### Example: Public Thrills
|
||||
1. **Testing the Waters** (intensity 2-3) - Whispers, secret touches, stolen kisses
|
||||
2. **Pushing Limits** (intensity 3-4) - Making out in risky spots, dressing rooms
|
||||
3. **All Eyes On Us** (intensity 4-5) - Sex in public places, exhibitionism
|
||||
|
||||
---
|
||||
|
||||
## Category Types
|
||||
|
||||
### Non-Sexual Categories (intensity 1-2)
|
||||
- Social Life, Quality Time, Kitchen Chemistry
|
||||
- Focus on bonding, activities, preferences
|
||||
- partner_text usually NULL (symmetric activities)
|
||||
- Great fit for who_likely, audio, and photo questions
|
||||
|
||||
### Sexual Categories (intensity 3-5)
|
||||
- Toy Time, Public Thrills, Kink Lab, Bedroom Adventures
|
||||
- Mix of symmetric and asymmetric questions
|
||||
- Always create inverse questions for asymmetric content
|
||||
- Mix of symmetric and asymmetric swipe questions
|
||||
- Always create inverse questions for asymmetric swipe content
|
||||
- who_likely questions can still work here ("Who is more likely to initiate?")
|
||||
|
||||
### Mixed Categories
|
||||
- Long Distance, Romance & Sensuality
|
||||
- Start non-sexual, progress to sexual
|
||||
- Clear intensity progression across packs
|
||||
- Good variety of all question types
|
||||
|
||||
---
|
||||
|
||||
@@ -231,11 +269,16 @@ Most categories should have 3-5 packs that progress in intensity:
|
||||
- Think about what 25-40 year olds actually do
|
||||
- Include multiple perspectives per kink/activity
|
||||
- Make activities feel achievable and fun
|
||||
- Include a mix of question types in every pack
|
||||
- Make who_likely questions fun and debatable
|
||||
- Make audio prompts feel natural to speak to
|
||||
- Make photo prompts easy to respond to (not requiring elaborate setups)
|
||||
|
||||
### DON'T:
|
||||
- Use cheesy phrases ("make love", "intimate connection", "souls intertwining")
|
||||
- Assume heterosexual couples by default
|
||||
- Create one-sided asymmetric questions (always make the inverse)
|
||||
- Create one-sided asymmetric swipe questions (always make the inverse)
|
||||
- Create inverses for who_likely, audio, or photo (they're symmetric)
|
||||
- Use time-specific language
|
||||
- Be preachy or educational in tone
|
||||
- Include judgment about activities
|
||||
@@ -257,22 +300,23 @@ When asked to create a pack:
|
||||
|
||||
1. **Understand the category and position**
|
||||
- What category does this belong to?
|
||||
- Where in the progression is this pack? (beginner/intermediate/advanced)
|
||||
- What intensity range?
|
||||
- What packs already exist?
|
||||
- Is the category explicit or non-explicit?
|
||||
|
||||
2. **Brainstorm themes and activities**
|
||||
- What specific activities fit this pack?
|
||||
- What makes this pack different from adjacent packs?
|
||||
- What progression from previous pack?
|
||||
- What question types fit best for this theme?
|
||||
|
||||
3. **Generate questions**
|
||||
- Mix of symmetric (null partner_text) and asymmetric questions
|
||||
- For EVERY asymmetric question, immediately create its inverse
|
||||
- Mix of swipe, who_likely, audio, and photo types
|
||||
- For swipe: mix of symmetric (null partner_text) and asymmetric questions
|
||||
- For EVERY asymmetric swipe question, immediately create its inverse
|
||||
- Vary sentence structure and openers
|
||||
- Target ~30-50 questions per pack
|
||||
|
||||
4. **Review for balance**
|
||||
- Check intensity distribution
|
||||
- Check type distribution
|
||||
- Ensure all perspectives covered
|
||||
- Remove duplicates or too-similar questions
|
||||
- Verify no cliches or cheesy language
|
||||
@@ -286,137 +330,125 @@ When asked to create a pack:
|
||||
|
||||
## Output Format
|
||||
|
||||
When generating packs, output in this format (with inverse_of linking for pairs):
|
||||
When generating packs, output in this format:
|
||||
|
||||
```sql
|
||||
-- Pack: [Pack Name]
|
||||
-- Category: [Category Name]
|
||||
-- Description: [Pack description]
|
||||
-- Intensity range: [X-Y]
|
||||
|
||||
-- For inverse pairs, insert primary first (inverse_of = NULL), then inverse pointing to primary
|
||||
INSERT INTO questions (id, pack_id, text, partner_text, intensity, allowed_couple_genders, target_user_genders, required_props, inverse_of) VALUES
|
||||
-- Pair 1
|
||||
('uuid-primary-1', '[pack_id]', 'Spank your partner', 'Be spanked by your partner', 3, NULL, NULL, NULL, NULL),
|
||||
('uuid-inverse-1', '[pack_id]', 'Be spanked by your partner', 'Spank your partner', 3, NULL, NULL, NULL, 'uuid-primary-1'),
|
||||
-- Pair 2 (with targeting)
|
||||
('uuid-primary-2', '[pack_id]', 'Give your partner a blowjob', 'Receive a blowjob from your partner', 3, ARRAY['male+male', 'female+male'], NULL, NULL, NULL),
|
||||
('uuid-inverse-2', '[pack_id]', 'Receive a blowjob from your partner', 'Give your partner a blowjob', 3, ARRAY['male+male', 'female+male'], NULL, NULL, 'uuid-primary-2'),
|
||||
-- Symmetric (no inverse needed)
|
||||
(gen_random_uuid(), '[pack_id]', 'Cook dinner together', NULL, 1, NULL, NULL, NULL, NULL);
|
||||
-- Swipe inverse pairs: insert primary first (inverse_of = NULL), then inverse pointing to primary
|
||||
INSERT INTO questions (id, pack_id, text, partner_text, intensity, question_type, config, allowed_couple_genders, target_user_genders, required_props, inverse_of) VALUES
|
||||
-- Swipe pair 1
|
||||
('uuid-primary-1', '[pack_id]', 'Spank your partner', 'Be spanked by your partner', 3, 'swipe', '{}', NULL, NULL, NULL, NULL),
|
||||
('uuid-inverse-1', '[pack_id]', 'Be spanked by your partner', 'Spank your partner', 3, 'swipe', '{}', NULL, NULL, NULL, 'uuid-primary-1'),
|
||||
-- Symmetric swipe (no inverse)
|
||||
(gen_random_uuid(), '[pack_id]', 'Cook dinner together', NULL, 1, 'swipe', '{}', NULL, NULL, NULL, NULL),
|
||||
-- who_likely (always symmetric, no inverse)
|
||||
(gen_random_uuid(), '[pack_id]', 'Who is more likely to burn dinner?', NULL, 1, 'who_likely', '{}', NULL, NULL, NULL, NULL),
|
||||
-- audio (always symmetric, no inverse)
|
||||
(gen_random_uuid(), '[pack_id]', 'Describe your perfect date in 30 seconds', NULL, 1, 'audio', '{"max_duration_seconds": 60}', NULL, NULL, NULL, NULL),
|
||||
-- photo (always symmetric, no inverse)
|
||||
(gen_random_uuid(), '[pack_id]', 'Share a photo that makes you smile', NULL, 1, 'photo', '{}', NULL, NULL, NULL, NULL);
|
||||
```
|
||||
|
||||
Or as a table (include inverse_of column):
|
||||
|
||||
| ID | Text | Partner Text | Intensity | Couple Targets | Initiator | Props | inverse_of |
|
||||
|----|------|--------------|-----------|----------------|-----------|-------|------------|
|
||||
| uuid-1 | Spank your partner | Be spanked | 3 | NULL | NULL | NULL | NULL |
|
||||
| uuid-2 | Be spanked by partner | Spank your partner | 3 | NULL | NULL | NULL | uuid-1 |
|
||||
| uuid-3 | Cook together | null | 1 | NULL | NULL | NULL | NULL |
|
||||
|
||||
---
|
||||
|
||||
## Examples of Good Questions
|
||||
|
||||
### Non-Sexual (Intensity 1)
|
||||
### Swipe - Non-Sexual (Intensity 1)
|
||||
```
|
||||
text: "Cook the same recipe together over video call"
|
||||
partner_text: NULL
|
||||
intensity: 1
|
||||
allowed_couple_genders: NULL
|
||||
target_user_genders: NULL
|
||||
required_props: NULL
|
||||
question_type: swipe
|
||||
config: {}
|
||||
```
|
||||
|
||||
### Romantic (Intensity 2)
|
||||
### Swipe - Romantic (Intensity 2)
|
||||
```
|
||||
text: "Send a tipsy voice note telling them you miss them"
|
||||
partner_text: NULL
|
||||
intensity: 2
|
||||
allowed_couple_genders: NULL
|
||||
target_user_genders: NULL
|
||||
required_props: NULL
|
||||
question_type: swipe
|
||||
config: {}
|
||||
```
|
||||
|
||||
### Playful (Intensity 3) - With Props and Inverse Linking
|
||||
### Swipe - Playful (Intensity 3) - With Props and Inverse
|
||||
```
|
||||
id: uuid-blindfold-primary
|
||||
text: "Blindfold your partner and tease them with different sensations"
|
||||
partner_text: "Be blindfolded while your partner teases you"
|
||||
intensity: 3
|
||||
allowed_couple_genders: NULL
|
||||
target_user_genders: NULL
|
||||
question_type: swipe
|
||||
config: {}
|
||||
required_props: ["blindfold"]
|
||||
inverse_of: NULL
|
||||
```
|
||||
AND its inverse (linked via inverse_of):
|
||||
AND its inverse:
|
||||
```
|
||||
id: uuid-blindfold-inverse
|
||||
text: "Be blindfolded while your partner teases you"
|
||||
partner_text: "Blindfold your partner and tease them with different sensations"
|
||||
intensity: 3
|
||||
allowed_couple_genders: NULL
|
||||
target_user_genders: NULL
|
||||
question_type: swipe
|
||||
config: {}
|
||||
required_props: ["blindfold"]
|
||||
inverse_of: uuid-blindfold-primary
|
||||
```
|
||||
|
||||
### Steamy (Intensity 4) - With Props and Targeting
|
||||
### who_likely
|
||||
```
|
||||
text: "Control your partner's remote vibrator while out at dinner"
|
||||
partner_text: "Wear a remote vibrator at dinner while your partner controls it"
|
||||
intensity: 4
|
||||
allowed_couple_genders: NULL
|
||||
target_user_genders: NULL
|
||||
required_props: ["remote vibrator"]
|
||||
```
|
||||
AND its inverse:
|
||||
```
|
||||
text: "Wear a remote vibrator at dinner while your partner controls it"
|
||||
partner_text: "Control your partner's remote vibrator while out at dinner"
|
||||
intensity: 4
|
||||
allowed_couple_genders: NULL
|
||||
target_user_genders: NULL
|
||||
required_props: ["remote vibrator"]
|
||||
text: "Who is more likely to forget an anniversary?"
|
||||
partner_text: NULL
|
||||
intensity: 1
|
||||
question_type: who_likely
|
||||
config: {}
|
||||
```
|
||||
|
||||
### With Couple Targeting (Intensity 3)
|
||||
### audio
|
||||
```
|
||||
text: "Describe the moment you knew you loved your partner"
|
||||
partner_text: NULL
|
||||
intensity: 2
|
||||
question_type: audio
|
||||
config: {"max_duration_seconds": 60}
|
||||
```
|
||||
|
||||
### photo
|
||||
```
|
||||
text: "Share the oldest photo you have of the two of you"
|
||||
partner_text: NULL
|
||||
intensity: 1
|
||||
question_type: photo
|
||||
config: {}
|
||||
```
|
||||
|
||||
### Swipe - Steamy (Intensity 4) - With Couple Targeting
|
||||
```
|
||||
text: "Give your partner a blowjob in a risky location"
|
||||
partner_text: "Receive a blowjob from your partner in a risky location"
|
||||
intensity: 3
|
||||
allowed_couple_genders: ["male+male", "female+male"] -- requires penis
|
||||
target_user_genders: NULL
|
||||
required_props: NULL
|
||||
question_type: swipe
|
||||
config: {}
|
||||
allowed_couple_genders: ["male+male", "female+male"]
|
||||
```
|
||||
|
||||
### With Initiator Targeting (Intensity 4)
|
||||
```
|
||||
text: "Swallow your partner's cum"
|
||||
partner_text: "Have your partner swallow your cum"
|
||||
intensity: 4
|
||||
allowed_couple_genders: ["male+male", "female+male"] -- requires penis
|
||||
target_user_genders: ["female"] -- in M+F, female receives
|
||||
required_props: NULL
|
||||
```
|
||||
|
||||
### Intense (Intensity 5)
|
||||
### Swipe - Intense (Intensity 5)
|
||||
```
|
||||
text: "Edge your partner repeatedly, denying release until you decide"
|
||||
partner_text: "Be edged and denied until your partner allows you to finish"
|
||||
intensity: 5
|
||||
allowed_couple_genders: NULL
|
||||
target_user_genders: NULL
|
||||
required_props: NULL
|
||||
question_type: swipe
|
||||
config: {}
|
||||
```
|
||||
AND its inverse:
|
||||
```
|
||||
text: "Be edged and denied until your partner allows you to finish"
|
||||
partner_text: "Edge your partner repeatedly, denying release until you decide"
|
||||
intensity: 5
|
||||
allowed_couple_genders: NULL
|
||||
target_user_genders: NULL
|
||||
required_props: NULL
|
||||
question_type: swipe
|
||||
config: {}
|
||||
```
|
||||
|
||||
---
|
||||
@@ -425,7 +457,7 @@ required_props: NULL
|
||||
|
||||
**Language & Format:**
|
||||
- [ ] Uses "your partner" (no gendered pronouns like him/her/he/she)
|
||||
- [ ] Questions are proposals, not interview questions
|
||||
- [ ] Swipe questions are proposals, not interview questions
|
||||
- [ ] No wishy-washy language ("Would you...", "Have you ever...")
|
||||
- [ ] No time-specific language (tonight, now, today)
|
||||
- [ ] 5-12 words per question (concise)
|
||||
@@ -433,26 +465,35 @@ required_props: NULL
|
||||
- [ ] Mix of sentence structures
|
||||
- [ ] Appropriate for 25-40 year old couples
|
||||
|
||||
**Question Types:**
|
||||
- [ ] Every question has a `question_type` set
|
||||
- [ ] who_likely uses "Who is more likely to..." format
|
||||
- [ ] audio prompts invite a natural spoken response
|
||||
- [ ] photo prompts invite a visual response
|
||||
- [ ] Non-swipe types have partner_text = NULL and inverse_of = NULL
|
||||
- [ ] Type distribution is balanced
|
||||
|
||||
**Structure:**
|
||||
- [ ] All asymmetric questions have their inverse created (these are NOT duplicates - they're required pairs)
|
||||
- [ ] All inverse pairs are linked via `inverse_of` column (inverse points to primary's UUID)
|
||||
- [ ] All asymmetric swipe questions have their inverse created
|
||||
- [ ] All inverse pairs are linked via `inverse_of` column
|
||||
- [ ] Partner text is appealing (not clinical)
|
||||
- [ ] Partner text frames responses as "allowing" not forced
|
||||
- [ ] No actual duplicates (same `text` appearing twice)
|
||||
|
||||
**Database Linking:**
|
||||
- [ ] Primary questions have `inverse_of = NULL`
|
||||
- [ ] Inverse questions have `inverse_of = <primary_question_uuid>`
|
||||
- [ ] Symmetric questions have `inverse_of = NULL`
|
||||
- [ ] Primary swipe questions have `inverse_of = NULL`
|
||||
- [ ] Inverse swipe questions have `inverse_of = <primary_question_uuid>`
|
||||
- [ ] Symmetric swipe questions have `inverse_of = NULL`
|
||||
- [ ] Non-swipe questions have `inverse_of = NULL`
|
||||
|
||||
**Accuracy:**
|
||||
- [ ] Intensity levels match the activities
|
||||
- [ ] Couple targeting is correct (NULL unless anatomy-specific)
|
||||
- [ ] Initiator targeting is correct for asymmetric questions
|
||||
- [ ] Initiator targeting is correct for asymmetric swipe questions
|
||||
- [ ] Required props are identified where needed
|
||||
- [ ] Consider same-sex couples
|
||||
|
||||
**Consistency:**
|
||||
- [ ] No mixed anatomy in alternatives
|
||||
- [ ] Tone matches explicit/non-explicit category
|
||||
- [ ] Pack fits its position in the progression
|
||||
- [ ] Pack fits its position in the category
|
||||
@@ -37,7 +37,7 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { Crown, Users, MessageCircle, ChevronRight, ThumbsUp, ThumbsDown, Minus, Gift, Image, Video as VideoIcon, Target, User, Eye, EyeOff, CheckCircle, Package, Sparkles, Flame, Trophy, Calendar } from 'lucide-react';
|
||||
import { Crown, Users, MessageCircle, ChevronRight, ThumbsUp, ThumbsDown, Minus, Gift, Image, Video as VideoIcon, Target, User, Eye, EyeOff, CheckCircle, Package, Sparkles, Flame, Trophy, Calendar, AlertCircle, Clock } from 'lucide-react';
|
||||
import { format } from 'date-fns';
|
||||
import { IconPreview } from '@/components/ui/icon-picker';
|
||||
|
||||
@@ -130,6 +130,20 @@ interface FeatureInterest {
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
interface OutstandingQuestion {
|
||||
id: string;
|
||||
text: string;
|
||||
intensity: number | null;
|
||||
pack: {
|
||||
id: string;
|
||||
name: string;
|
||||
};
|
||||
partner_response: {
|
||||
answer: 'yes' | 'no' | 'maybe';
|
||||
created_at: string | null;
|
||||
};
|
||||
}
|
||||
|
||||
interface CoupleStreak {
|
||||
id: string;
|
||||
couple_id: string;
|
||||
@@ -277,6 +291,13 @@ export function UserDetailPage() {
|
||||
const [enabledPacks, setEnabledPacks] = useState<EnabledPack[]>([]);
|
||||
const [featureInterests, setFeatureInterests] = useState<FeatureInterest[]>([]);
|
||||
const [streak, setStreak] = useState<CoupleStreak | null>(null);
|
||||
const [outstandingQuestions, setOutstandingQuestions] = useState<OutstandingQuestion[]>([]);
|
||||
const [outstandingPage, setOutstandingPage] = useState(1);
|
||||
const [outstandingPageSize, setOutstandingPageSize] = useState(10);
|
||||
const [outstandingTotal, setOutstandingTotal] = useState(0);
|
||||
const [gapDisabled, setGapDisabled] = useState(false);
|
||||
const [gapThreshold, setGapThreshold] = useState(0);
|
||||
const [netGap, setNetGap] = useState(0);
|
||||
const [responsesPage, setResponsesPage] = useState(1);
|
||||
const [responsesPageSize, setResponsesPageSize] = useState(10);
|
||||
const [responsesTotal, setResponsesTotal] = useState(0);
|
||||
@@ -467,19 +488,115 @@ export function UserDetailPage() {
|
||||
} else {
|
||||
setEnabledPacks((enabledPacksData || []) as unknown as EnabledPack[]);
|
||||
}
|
||||
|
||||
// Fetch outstanding questions (partner answered, user hasn't)
|
||||
// Use partner from state (set earlier in the first couple_id block)
|
||||
const partnerProfile = partner || (await supabase.from('profiles').select('id, name').eq('couple_id', profileData.couple_id).neq('id', userId).maybeSingle()).data;
|
||||
if (partnerProfile) {
|
||||
// Get question IDs user has already answered
|
||||
const { data: userResponseIds } = await supabase
|
||||
.from('responses')
|
||||
.select('question_id')
|
||||
.eq('user_id', userId);
|
||||
const answeredIds = new Set((userResponseIds || []).map((r: any) => r.question_id));
|
||||
|
||||
// Get all partner responses in this couple
|
||||
const { data: partnerResponses } = await supabase
|
||||
.from('responses')
|
||||
.select(`
|
||||
question_id,
|
||||
answer,
|
||||
created_at
|
||||
`)
|
||||
.eq('user_id', partnerProfile.id)
|
||||
.eq('couple_id', profileData.couple_id)
|
||||
.order('created_at', { ascending: false });
|
||||
|
||||
// Filter to only questions user hasn't answered
|
||||
const unansweredPartnerResponses = (partnerResponses || []).filter((r: any) => !answeredIds.has(r.question_id));
|
||||
const unansweredQuestionIds = unansweredPartnerResponses.map((r: any) => r.question_id);
|
||||
|
||||
if (unansweredQuestionIds.length > 0) {
|
||||
// Fetch question details (no pack filter — admin sees all)
|
||||
const { data: questionDetails } = await supabase
|
||||
.from('questions')
|
||||
.select('id, text, intensity, pack:question_packs(id, name)')
|
||||
.in('id', unansweredQuestionIds);
|
||||
|
||||
const questionMap = new Map((questionDetails || []).map((q: any) => [q.id, q]));
|
||||
const partnerResponseMap = new Map(unansweredPartnerResponses.map((r: any) => [r.question_id, r]));
|
||||
|
||||
const outstanding: OutstandingQuestion[] = [];
|
||||
for (const qId of unansweredQuestionIds) {
|
||||
const q = questionMap.get(qId);
|
||||
const pr = partnerResponseMap.get(qId);
|
||||
if (q && pr) {
|
||||
outstanding.push({
|
||||
id: qId,
|
||||
text: q.text || '',
|
||||
intensity: q.intensity || null,
|
||||
pack: { id: q.pack?.id || '', name: q.pack?.name || '' },
|
||||
partner_response: { answer: pr.answer, created_at: pr.created_at },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
setOutstandingTotal(outstanding.length);
|
||||
const outFrom = (outstandingPage - 1) * outstandingPageSize;
|
||||
setOutstandingQuestions(outstanding.slice(outFrom, outFrom + outstandingPageSize));
|
||||
} else {
|
||||
setOutstandingQuestions([]);
|
||||
setOutstandingTotal(0);
|
||||
}
|
||||
|
||||
// Calculate gap status
|
||||
const { data: appConfig } = await supabase
|
||||
.from('app_config')
|
||||
.select('answer_gap_threshold')
|
||||
.limit(1)
|
||||
.maybeSingle();
|
||||
|
||||
const threshold = appConfig?.answer_gap_threshold ?? 10;
|
||||
setGapThreshold(threshold);
|
||||
|
||||
if (threshold > 0) {
|
||||
// Count questions user answered that partner hasn't
|
||||
const partnerAllAnswered = new Set((partnerResponses || []).map((r: any) => r.question_id));
|
||||
const { data: userResponses } = await supabase
|
||||
.from('responses')
|
||||
.select('question_id')
|
||||
.eq('user_id', userId)
|
||||
.eq('couple_id', profileData.couple_id);
|
||||
const userAhead = (userResponses || []).filter((r: any) => !partnerAllAnswered.has(r.question_id)).length;
|
||||
const partnerAhead = unansweredPartnerResponses.length;
|
||||
const gap = Math.max(0, userAhead - partnerAhead);
|
||||
setNetGap(gap);
|
||||
setGapDisabled(gap >= threshold);
|
||||
} else {
|
||||
setNetGap(0);
|
||||
setGapDisabled(false);
|
||||
}
|
||||
} else {
|
||||
setOutstandingQuestions([]);
|
||||
setOutstandingTotal(0);
|
||||
setGapDisabled(false);
|
||||
}
|
||||
} else {
|
||||
setMatches([]);
|
||||
setMatchesTotal(0);
|
||||
setMediaMessages([]);
|
||||
setMediaTotal(0);
|
||||
setEnabledPacks([]);
|
||||
setOutstandingQuestions([]);
|
||||
setOutstandingTotal(0);
|
||||
setGapDisabled(false);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load user data:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [userId, responsesPage, responsesPageSize, matchesPage, matchesPageSize, mediaPage, mediaPageSize]);
|
||||
}, [userId, responsesPage, responsesPageSize, matchesPage, matchesPageSize, mediaPage, mediaPageSize, outstandingPage, outstandingPageSize]);
|
||||
|
||||
const { status: profileStatus } = useRealtimeSubscription<Profile>({ table: 'profiles', filter: userId ? `id=eq.${userId}` : undefined, enabled: !!userId, onUpdate: useCallback(({ new: updated }: { old: Profile; new: Profile }) => { setProfile(updated); }, []) });
|
||||
const { status: responsesStatus } = useRealtimeSubscription<Response>({ table: 'responses', filter: userId ? `user_id=eq.${userId}` : undefined, enabled: !!userId, onInsert: fetchData, onDelete: fetchData });
|
||||
@@ -490,6 +607,7 @@ export function UserDetailPage() {
|
||||
setResponsesPage(1);
|
||||
setMatchesPage(1);
|
||||
setMediaPage(1);
|
||||
setOutstandingPage(1);
|
||||
}, [userId]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -526,6 +644,8 @@ export function UserDetailPage() {
|
||||
<h1 className="text-2xl font-bold">{profile.name || 'Unnamed User'}</h1>
|
||||
<RealtimeStatusIndicator status={profileStatus === 'SUBSCRIBED' && responsesStatus === 'SUBSCRIBED' ? 'SUBSCRIBED' : profileStatus} showLabel />
|
||||
{profile.is_premium && <Badge className="bg-amber-500"><Crown className="h-3 w-3 mr-1" />Premium</Badge>}
|
||||
{outstandingTotal > 0 && <Badge variant="outline" className="gap-1"><Clock className="h-3 w-3" />{outstandingTotal} outstanding</Badge>}
|
||||
{gapDisabled && <Badge variant="destructive" className="gap-1"><AlertCircle className="h-3 w-3" />Gap blocked ({netGap}/{gapThreshold})</Badge>}
|
||||
{!profile.is_premium && hasPermission(PERMISSION_KEYS.GIFT_PREMIUM) && <Dialog open={upgradeOpen} onOpenChange={setUpgradeOpen}><DialogTrigger asChild><Button size="sm" variant="outline" className="ml-2 gap-1 h-7"><Gift className="h-3.5 w-3.5" />Gift Premium</Button></DialogTrigger><DialogContent><DialogHeader><DialogTitle>Gift Premium Access</DialogTitle><DialogDescription>Manually upgrade this user to premium status.</DialogDescription></DialogHeader><div className="grid gap-4 py-4"><div className="grid gap-2"><Label htmlFor="expiry-type">Duration</Label><Select value={expiryType} onValueChange={setExpiryType}><SelectTrigger id="expiry-type"><SelectValue placeholder="Select duration" /></SelectTrigger><SelectContent><SelectItem value="forever">Lifetime (No Expiry)</SelectItem><SelectItem value="1_month">1 Month</SelectItem><SelectItem value="1_year">1 Year</SelectItem><SelectItem value="custom">Custom Date</SelectItem></SelectContent></Select></div>{expiryType === 'custom' && (<div className="grid gap-2"><Label htmlFor="custom-date">Expiry Date</Label><Input id="custom-date" type="datetime-local" value={customDate} onChange={(e) => setCustomDate(e.target.value)} /></div>)}</div><DialogFooter><Button variant="outline" onClick={() => setUpgradeOpen(false)}>Cancel</Button><Button onClick={handleUpgrade} disabled={upgrading}>{upgrading ? 'Upgrading...' : 'Confirm Upgrade'}</Button></DialogFooter></DialogContent></Dialog>}
|
||||
</div>
|
||||
<div className="text-muted-foreground mb-4 flex items-center gap-2">
|
||||
@@ -633,6 +753,7 @@ export function UserDetailPage() {
|
||||
{canViewResponses && <TabsTrigger value="responses">Responses ({responsesTotal})</TabsTrigger>}
|
||||
{canViewMatches && <TabsTrigger value="matches" disabled={!profile.couple_id}>Matches ({matchesTotal})</TabsTrigger>}
|
||||
{canViewMedia && <TabsTrigger value="media">Media ({mediaTotal})</TabsTrigger>}
|
||||
{canViewResponses && <TabsTrigger value="outstanding" disabled={!profile.couple_id || !partner}>Outstanding ({outstandingTotal}){gapDisabled && <AlertCircle className="h-3 w-3 ml-1 text-destructive" />}</TabsTrigger>}
|
||||
<TabsTrigger value="packs" disabled={!profile.couple_id}>Enabled Packs ({enabledPacks.length})</TabsTrigger>
|
||||
</TabsList>
|
||||
{canViewResponses && <TabsContent value="responses" className="mt-4">{responses.length === 0 ? <Card className="flex flex-col items-center justify-center py-12"><p className="text-muted-foreground">No responses yet</p></Card> : <div className="space-y-4"><div className="rounded-md border"><Table><TableHeader><TableRow><TableHead>Question</TableHead><TableHead>Pack</TableHead><TableHead className="w-24">Answer</TableHead><TableHead className="w-32">Date</TableHead></TableRow></TableHeader><TableBody>{responses.map((r) => (<TableRow key={r.id}><TableCell className="max-w-md">{r.question.pack?.id ? (<Link to={`/packs/${r.question.pack.id}/questions`} className="line-clamp-2 text-primary hover:underline cursor-pointer">{r.question.text}</Link>) : (<span className="line-clamp-2">{r.question.text}</span>)}</TableCell><TableCell>{r.question.pack?.id ? (<Link to={`/packs/${r.question.pack.id}/questions`} className="text-primary hover:underline">{r.question.pack.name}</Link>) : (<span className="text-muted-foreground">{r.question.pack?.name || '—'}</span>)}</TableCell><TableCell><div className="flex items-center gap-2">{answerIcons[r.answer]}<span className="capitalize">{r.answer}</span></div></TableCell><TableCell className="text-muted-foreground text-sm">{r.created_at ? format(new Date(r.created_at), 'MMM d, yyyy') : '—'}</TableCell></TableRow>))}</TableBody></Table></div><PaginationControls page={responsesPage} pageSize={responsesPageSize} totalCount={responsesTotal} onPageChange={setResponsesPage} onPageSizeChange={(size) => { setResponsesPage(1); setResponsesPageSize(size); }} /></div>}</TabsContent>}
|
||||
@@ -644,6 +765,86 @@ export function UserDetailPage() {
|
||||
<AdminDecryptedImage messageId={message.id} alt={`Media from ${message.created_at}`} />
|
||||
)}
|
||||
</div><CardContent className="p-3"><div className="flex items-center justify-between text-sm"><div className="truncate text-muted-foreground">{message.media_type === 'video' ? 'Video' : 'Image'}</div>{canViewChats && message.match_id && <Link to={`/users/${userId}/matches/${message.match_id}`} className="text-primary hover:underline text-xs">View Chat</Link>}</div><div className="text-xs text-muted-foreground mt-1">{message.created_at ? format(new Date(message.created_at), 'MMM d, yyyy') : '—'}</div></CardContent></Card>))}</div><PaginationControls page={mediaPage} pageSize={mediaPageSize} totalCount={mediaTotal} onPageChange={setMediaPage} onPageSizeChange={(size) => { setMediaPage(1); setMediaPageSize(size); }} /></div>}</TabsContent>}
|
||||
{canViewResponses && <TabsContent value="outstanding" className="mt-4">
|
||||
{gapDisabled && (
|
||||
<Card className="mb-4 border-destructive/50 bg-destructive/5">
|
||||
<CardContent className="pt-4 pb-4">
|
||||
<div className="flex items-center gap-2 text-destructive">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<span className="font-medium">Gap disabled</span>
|
||||
<span className="text-muted-foreground text-sm">- This user is {netGap} questions ahead of their partner (threshold: {gapThreshold}). They won't see new questions until their partner catches up.</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
{outstandingQuestions.length === 0 ? (
|
||||
<Card className="flex flex-col items-center justify-center py-12">
|
||||
<CheckCircle className="h-12 w-12 text-green-500 mb-4" />
|
||||
<p className="text-muted-foreground">No outstanding questions - all caught up!</p>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
<div className="rounded-md border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Question</TableHead>
|
||||
<TableHead>Pack</TableHead>
|
||||
<TableHead className="w-24">Intensity</TableHead>
|
||||
<TableHead className="w-32">Partner's Answer</TableHead>
|
||||
<TableHead className="w-36">Partner Answered</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{outstandingQuestions.map((q) => (
|
||||
<TableRow key={q.id}>
|
||||
<TableCell className="max-w-md">
|
||||
{q.pack?.id ? (
|
||||
<Link to={`/packs/${q.pack.id}/questions`} className="line-clamp-2 text-primary hover:underline cursor-pointer">{q.text}</Link>
|
||||
) : (
|
||||
<span className="line-clamp-2">{q.text}</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{q.pack?.id ? (
|
||||
<Link to={`/packs/${q.pack.id}/questions`} className="text-primary hover:underline">{q.pack.name}</Link>
|
||||
) : (
|
||||
<span className="text-muted-foreground">—</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{q.intensity ? (
|
||||
<div className="flex items-center gap-1">
|
||||
{Array.from({ length: q.intensity }, (_, i) => (
|
||||
<Flame key={i} className="h-3 w-3 text-orange-500" />
|
||||
))}
|
||||
</div>
|
||||
) : '—'}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-2">
|
||||
{answerIcons[q.partner_response.answer]}
|
||||
<span className="capitalize">{q.partner_response.answer}</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-muted-foreground text-sm">
|
||||
{q.partner_response.created_at ? format(new Date(q.partner_response.created_at), 'MMM d, yyyy') : '—'}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
<PaginationControls
|
||||
page={outstandingPage}
|
||||
pageSize={outstandingPageSize}
|
||||
totalCount={outstandingTotal}
|
||||
onPageChange={setOutstandingPage}
|
||||
onPageSizeChange={(size) => { setOutstandingPage(1); setOutstandingPageSize(size); }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>}
|
||||
<TabsContent value="packs" className="mt-4">
|
||||
{enabledPacks.length === 0 ? (
|
||||
<Card className="flex flex-col items-center justify-center py-12">
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
Alert,
|
||||
ActivityIndicator,
|
||||
Share,
|
||||
Linking,
|
||||
KeyboardAvoidingView,
|
||||
Platform,
|
||||
TouchableOpacity,
|
||||
@@ -141,12 +142,14 @@ export default function PairingScreen() {
|
||||
}
|
||||
};
|
||||
|
||||
const shareMessage = couple?.invite_code
|
||||
? `Join me on Sauci! Download the app at https://sauci.app and use my invite code to pair up: ${couple.invite_code}`
|
||||
: "";
|
||||
|
||||
const shareCode = async () => {
|
||||
if (couple?.invite_code) {
|
||||
try {
|
||||
await Share.share({
|
||||
message: `Join me on Sauci! Download the app at https://sauci.app and use my invite code to pair up: ${couple.invite_code}`,
|
||||
});
|
||||
await Share.share({ message: shareMessage });
|
||||
Events.codeShared();
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
@@ -154,6 +157,23 @@ export default function PairingScreen() {
|
||||
}
|
||||
};
|
||||
|
||||
const shareViaWhatsApp = async () => {
|
||||
const url = `whatsapp://send?text=${encodeURIComponent(shareMessage)}`;
|
||||
const canOpen = await Linking.canOpenURL(url);
|
||||
if (canOpen) {
|
||||
await Linking.openURL(url);
|
||||
Events.codeShared();
|
||||
} else {
|
||||
shareCode();
|
||||
}
|
||||
};
|
||||
|
||||
const shareViaSMS = async () => {
|
||||
const url = `sms:&body=${encodeURIComponent(shareMessage)}`;
|
||||
await Linking.openURL(url);
|
||||
Events.codeShared();
|
||||
};
|
||||
|
||||
const handleCancelPairing = async () => {
|
||||
Alert.alert(
|
||||
"Cancel Pairing",
|
||||
@@ -256,7 +276,15 @@ export default function PairingScreen() {
|
||||
</GlassCard>
|
||||
</Animated.View>
|
||||
|
||||
{/* Share Button */}
|
||||
{/* CTA Text */}
|
||||
<Animated.View
|
||||
entering={FadeInDown.delay(350).duration(500)}
|
||||
style={styles.ctaSection}
|
||||
>
|
||||
<Text style={styles.ctaText}>Send this to your partner to get started</Text>
|
||||
</Animated.View>
|
||||
|
||||
{/* Primary Share Button */}
|
||||
<Animated.View
|
||||
entering={FadeInDown.delay(400).duration(500)}
|
||||
style={styles.section}
|
||||
@@ -264,12 +292,28 @@ export default function PairingScreen() {
|
||||
<GlassButton
|
||||
onPress={shareCode}
|
||||
fullWidth
|
||||
icon={<Ionicons name="share-outline" size={20} color={colors.text} />}
|
||||
size="lg"
|
||||
icon={<Ionicons name="share-outline" size={22} color={colors.text} />}
|
||||
>
|
||||
Share Code
|
||||
Share Invite Code
|
||||
</GlassButton>
|
||||
</Animated.View>
|
||||
|
||||
{/* Quick share buttons */}
|
||||
<Animated.View
|
||||
entering={FadeInDown.delay(450).duration(500)}
|
||||
style={styles.quickShareRow}
|
||||
>
|
||||
<TouchableOpacity style={styles.quickShareButton} onPress={shareViaSMS} activeOpacity={0.7}>
|
||||
<Ionicons name="chatbubble-outline" size={20} color={colors.text} />
|
||||
<Text style={styles.quickShareLabel}>Messages</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity style={styles.quickShareButton} onPress={shareViaWhatsApp} activeOpacity={0.7}>
|
||||
<Ionicons name="logo-whatsapp" size={20} color={colors.text} />
|
||||
<Text style={styles.quickShareLabel}>WhatsApp</Text>
|
||||
</TouchableOpacity>
|
||||
</Animated.View>
|
||||
|
||||
{/* Waiting indicator */}
|
||||
<Animated.View
|
||||
entering={FadeInDown.delay(500).duration(500)}
|
||||
@@ -414,61 +458,61 @@ const styles = StyleSheet.create({
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
},
|
||||
backButton: {
|
||||
width: 44,
|
||||
height: 44,
|
||||
borderRadius: 22,
|
||||
backgroundColor: colors.backgroundLight,
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
marginRight: spacing.md,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.border,
|
||||
},
|
||||
// ...
|
||||
input: {
|
||||
backgroundColor: colors.background, // Flat background
|
||||
borderRadius: radius.md,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.border,
|
||||
padding: spacing.md,
|
||||
...typography.headline,
|
||||
color: colors.text,
|
||||
textAlign: "center",
|
||||
letterSpacing: 4,
|
||||
fontFamily: Platform.OS === "ios" ? "Courier" : "monospace",
|
||||
marginBottom: spacing.md,
|
||||
},
|
||||
// ...
|
||||
dividerLine: {
|
||||
flex: 1,
|
||||
height: 1,
|
||||
backgroundColor: colors.border,
|
||||
},
|
||||
// ...
|
||||
copyIcon: {
|
||||
width: 36,
|
||||
height: 36,
|
||||
borderRadius: 18,
|
||||
backgroundColor: colors.background,
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
marginLeft: spacing.md,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.border,
|
||||
},
|
||||
// ...
|
||||
waitingBadge: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
backgroundColor: colors.backgroundLight,
|
||||
paddingHorizontal: spacing.md,
|
||||
paddingVertical: spacing.sm,
|
||||
borderRadius: radius.full,
|
||||
gap: spacing.sm,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.border,
|
||||
},
|
||||
backButton: {
|
||||
width: 44,
|
||||
height: 44,
|
||||
borderRadius: 22,
|
||||
backgroundColor: colors.backgroundLight,
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
marginRight: spacing.md,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.border,
|
||||
},
|
||||
// ...
|
||||
input: {
|
||||
backgroundColor: colors.background, // Flat background
|
||||
borderRadius: radius.md,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.border,
|
||||
padding: spacing.md,
|
||||
...typography.headline,
|
||||
color: colors.text,
|
||||
textAlign: "center",
|
||||
letterSpacing: 4,
|
||||
fontFamily: Platform.OS === "ios" ? "Courier" : "monospace",
|
||||
marginBottom: spacing.md,
|
||||
},
|
||||
// ...
|
||||
dividerLine: {
|
||||
flex: 1,
|
||||
height: 1,
|
||||
backgroundColor: colors.border,
|
||||
},
|
||||
// ...
|
||||
copyIcon: {
|
||||
width: 36,
|
||||
height: 36,
|
||||
borderRadius: 18,
|
||||
backgroundColor: colors.background,
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
marginLeft: spacing.md,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.border,
|
||||
},
|
||||
// ...
|
||||
waitingBadge: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
backgroundColor: colors.backgroundLight,
|
||||
paddingHorizontal: spacing.md,
|
||||
paddingVertical: spacing.sm,
|
||||
borderRadius: radius.full,
|
||||
gap: spacing.sm,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.border,
|
||||
},
|
||||
title: {
|
||||
...typography.title1,
|
||||
color: colors.text,
|
||||
@@ -508,13 +552,13 @@ const styles = StyleSheet.create({
|
||||
marginBottom: spacing.sm,
|
||||
marginLeft: spacing.xs,
|
||||
},
|
||||
|
||||
|
||||
divider: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
marginVertical: spacing.lg,
|
||||
},
|
||||
|
||||
|
||||
dividerText: {
|
||||
...typography.subhead,
|
||||
color: colors.textTertiary,
|
||||
@@ -531,7 +575,7 @@ const styles = StyleSheet.create({
|
||||
letterSpacing: 6,
|
||||
fontFamily: Platform.OS === "ios" ? "Courier" : "monospace",
|
||||
},
|
||||
|
||||
|
||||
tapToCopy: {
|
||||
...typography.caption1,
|
||||
color: colors.textTertiary,
|
||||
@@ -542,7 +586,7 @@ const styles = StyleSheet.create({
|
||||
alignItems: "center",
|
||||
marginTop: spacing.xl,
|
||||
},
|
||||
|
||||
|
||||
waitingText: {
|
||||
...typography.subhead,
|
||||
color: colors.textTertiary,
|
||||
@@ -559,4 +603,34 @@ const styles = StyleSheet.create({
|
||||
...typography.subhead,
|
||||
color: colors.error,
|
||||
},
|
||||
ctaSection: {
|
||||
alignItems: "center",
|
||||
marginBottom: spacing.md,
|
||||
},
|
||||
ctaText: {
|
||||
...typography.callout,
|
||||
color: colors.textSecondary,
|
||||
textAlign: "center",
|
||||
},
|
||||
quickShareRow: {
|
||||
flexDirection: "row",
|
||||
justifyContent: "center",
|
||||
gap: spacing.md,
|
||||
marginBottom: spacing.md,
|
||||
},
|
||||
quickShareButton: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: spacing.sm,
|
||||
backgroundColor: colors.backgroundLight,
|
||||
paddingHorizontal: spacing.md,
|
||||
paddingVertical: spacing.sm,
|
||||
borderRadius: radius.full,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.border,
|
||||
},
|
||||
quickShareLabel: {
|
||||
...typography.subhead,
|
||||
color: colors.text,
|
||||
},
|
||||
});
|
||||
@@ -1,8 +1,7 @@
|
||||
import React, { useEffect, useMemo } from 'react';
|
||||
import { View, StyleSheet, Text } from 'react-native';
|
||||
import { colors, spacing, typography } from '../../../theme';
|
||||
import { useNotificationPreferencesStore } from '../../../store';
|
||||
import { useAuthStore } from '../../../store';
|
||||
import { useNotificationPreferencesStore, useAuthStore } from '../../../store';
|
||||
import { SettingsSection } from './SettingsSection';
|
||||
import { SwitchItem } from './SwitchItem';
|
||||
|
||||
@@ -19,7 +18,7 @@ export const NotificationSettings: React.FC<NotificationSettingsProps> = ({
|
||||
onPushToggle,
|
||||
delay = 360,
|
||||
}) => {
|
||||
const { user } = useAuthStore();
|
||||
const { user, partner } = useAuthStore();
|
||||
const { preferences, isUpdating, fetchPreferences, updatePreference } = useNotificationPreferencesStore();
|
||||
|
||||
// Fetch notification preferences on mount when user is available
|
||||
@@ -38,6 +37,9 @@ export const NotificationSettings: React.FC<NotificationSettingsProps> = ({
|
||||
onPackChangesToggle: (value: boolean) => updatePreference('pack_changes_enabled', value),
|
||||
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 (
|
||||
@@ -133,6 +135,47 @@ export const NotificationSettings: React.FC<NotificationSettingsProps> = ({
|
||||
onValueChange={handlers.onStreakMilestonesToggle}
|
||||
disabled={isUpdating}
|
||||
/>
|
||||
|
||||
<View style={styles.itemDivider} />
|
||||
|
||||
<SwitchItem
|
||||
icon="calendar-outline"
|
||||
label="Weekly Recap"
|
||||
description="Sunday summary of your weekly matches"
|
||||
value={preferences.weekly_summary_enabled}
|
||||
onValueChange={handlers.onWeeklySummaryToggle}
|
||||
disabled={isUpdating}
|
||||
/>
|
||||
|
||||
{partner && (
|
||||
<>
|
||||
<View style={styles.itemDivider} />
|
||||
|
||||
<SwitchItem
|
||||
icon="time-outline"
|
||||
label="Catch-up Reminders"
|
||||
description="Reminders when your partner is ahead"
|
||||
value={preferences.catchup_reminders_enabled}
|
||||
onValueChange={handlers.onCatchupRemindersToggle}
|
||||
disabled={isUpdating}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{!partner && (
|
||||
<>
|
||||
<View style={styles.itemDivider} />
|
||||
|
||||
<SwitchItem
|
||||
icon="people-outline"
|
||||
label="Pairing Reminders"
|
||||
description="Reminders to share your invite code"
|
||||
value={preferences.unpaired_reminders_enabled}
|
||||
onValueChange={handlers.onUnpairedRemindersToggle}
|
||||
disabled={isUpdating}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</SettingsSection>
|
||||
|
||||
@@ -27,6 +27,11 @@ interface SwipeInfoStateLayoutProps {
|
||||
onPress: () => void;
|
||||
variant?: "primary" | "secondary";
|
||||
};
|
||||
secondaryAction?: {
|
||||
label: string;
|
||||
onPress: () => void;
|
||||
icon?: keyof typeof Ionicons.glyphMap;
|
||||
};
|
||||
}
|
||||
|
||||
export const SwipeInfoStateLayout = ({
|
||||
@@ -41,6 +46,7 @@ export const SwipeInfoStateLayout = ({
|
||||
separatorVariant = "rose",
|
||||
labelStyle,
|
||||
action,
|
||||
secondaryAction,
|
||||
}: SwipeInfoStateLayoutProps) => (
|
||||
<GradientBackground>
|
||||
<View style={styles.centerContainer}>
|
||||
@@ -88,6 +94,17 @@ export const SwipeInfoStateLayout = ({
|
||||
{action.label}
|
||||
</GlassButton>
|
||||
)}
|
||||
|
||||
{secondaryAction && (
|
||||
<GlassButton
|
||||
variant="secondary"
|
||||
onPress={secondaryAction.onPress}
|
||||
style={{ marginTop: spacing.sm }}
|
||||
icon={secondaryAction.icon ? <Ionicons name={secondaryAction.icon} size={18} color={colors.text} /> : undefined}
|
||||
>
|
||||
{secondaryAction.label}
|
||||
</GlassButton>
|
||||
)}
|
||||
</Animated.View>
|
||||
</View>
|
||||
</GradientBackground>
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import { Share } from "react-native";
|
||||
import { colors } from "../../../theme";
|
||||
import { useAuthStore } from "../../../store";
|
||||
import { Events } from "../../../lib/analytics";
|
||||
import { SwipeInfoStateLayout } from "./SwipeInfoStateLayout";
|
||||
|
||||
interface SwipeNoPartnerStateProps {
|
||||
@@ -8,6 +11,19 @@ interface SwipeNoPartnerStateProps {
|
||||
|
||||
export const SwipeNoPartnerState = ({ hasCouple, onPairPress }: SwipeNoPartnerStateProps) => {
|
||||
const accent = colors.premium.rose;
|
||||
const { couple } = useAuthStore();
|
||||
|
||||
const handleShareCode = async () => {
|
||||
if (!couple?.invite_code) return;
|
||||
try {
|
||||
await Share.share({
|
||||
message: `Join me on Sauci! Download the app at https://sauci.app and use my invite code to pair up: ${couple.invite_code}`,
|
||||
});
|
||||
Events.codeShared();
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<SwipeInfoStateLayout
|
||||
@@ -30,6 +46,11 @@ export const SwipeNoPartnerState = ({ hasCouple, onPairPress }: SwipeNoPartnerSt
|
||||
label: hasCouple ? "View Invite Code" : "Pair Now",
|
||||
onPress: onPairPress,
|
||||
}}
|
||||
secondaryAction={hasCouple && couple?.invite_code ? {
|
||||
label: "Share Code",
|
||||
onPress: handleShareCode,
|
||||
icon: "share-outline",
|
||||
} : undefined}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -19,7 +19,7 @@ Notifications.setNotificationHandler({
|
||||
|
||||
// Types
|
||||
export interface NotificationData {
|
||||
type: "match" | "message" | "match_digest" | "partner_activity" | "nudge";
|
||||
type: "match" | "message" | "match_digest" | "partner_activity" | "nudge" | "weekly_summary";
|
||||
match_id?: string;
|
||||
message_id?: string;
|
||||
count?: number;
|
||||
@@ -231,7 +231,7 @@ export function handleNotificationResponse(
|
||||
return;
|
||||
}
|
||||
|
||||
if (data.type === "match_digest") {
|
||||
if (data.type === "match_digest" || data.type === "weekly_summary") {
|
||||
router.push("/(app)/matches");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -11,6 +11,9 @@ export interface NotificationPreferences {
|
||||
pack_changes_enabled: boolean;
|
||||
new_packs_enabled: boolean;
|
||||
streak_milestones_enabled: boolean;
|
||||
weekly_summary_enabled: boolean;
|
||||
unpaired_reminders_enabled: boolean;
|
||||
catchup_reminders_enabled: boolean;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,398 @@
|
||||
import "jsr:@supabase/functions-js/edge-runtime.d.ts";
|
||||
import { createClient } from "https://esm.sh/@supabase/supabase-js@2.45.0?target=deno";
|
||||
|
||||
/**
|
||||
* Send catch-up reminder notifications to the "behind" partner.
|
||||
* Called by pg_cron daily at 17:00 UTC.
|
||||
*
|
||||
* Escalation schedule:
|
||||
* - First reminder after 24h of pending questions
|
||||
* - Follow-up reminders every 3 days
|
||||
*
|
||||
* Skips if: user active in last 6h, catchup_reminders_enabled = false, no push token
|
||||
*/
|
||||
|
||||
interface ExpoPushMessage {
|
||||
to: string;
|
||||
title: string;
|
||||
body: string;
|
||||
sound?: string;
|
||||
data?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
async function sendExpoPushNotification(message: ExpoPushMessage) {
|
||||
const response = await fetch("https://exp.host/--/api/v2/push/send", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Accept: "application/json",
|
||||
},
|
||||
body: JSON.stringify(message),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.text();
|
||||
console.error("Expo push error:", error);
|
||||
throw new Error(`Failed to send push notification: ${error}`);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
Deno.serve(async (req) => {
|
||||
if (req.method !== "POST" && req.method !== "GET") {
|
||||
return new Response("Method not allowed", { status: 405 });
|
||||
}
|
||||
|
||||
const supabase = createClient(
|
||||
Deno.env.get("SUPABASE_URL")!,
|
||||
Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!
|
||||
);
|
||||
|
||||
try {
|
||||
const now = new Date();
|
||||
const twentyFourHoursAgo = new Date(now.getTime() - 24 * 60 * 60 * 1000);
|
||||
const threeDaysAgo = new Date(now.getTime() - 3 * 24 * 60 * 60 * 1000);
|
||||
const sixHoursAgo = new Date(now.getTime() - 6 * 60 * 60 * 1000);
|
||||
|
||||
// Find all couples and count unanswered questions per user
|
||||
// A "behind" user has questions their partner answered but they haven't
|
||||
const { data: behindUsers, error: queryError } = await supabase.rpc(
|
||||
"get_behind_partners"
|
||||
);
|
||||
|
||||
// If the RPC doesn't exist yet, fall back to a raw query
|
||||
let candidates: Array<{
|
||||
behind_user_id: string;
|
||||
partner_name: string;
|
||||
pending_count: number;
|
||||
}>;
|
||||
|
||||
if (queryError) {
|
||||
// Fallback: use SQL directly
|
||||
const { data, error } = await supabase.from("couples").select(
|
||||
"id, user1_id, user2_id"
|
||||
);
|
||||
if (error) throw error;
|
||||
if (!data || data.length === 0) {
|
||||
return new Response(
|
||||
JSON.stringify({ success: true, sent: 0, message: "No couples" }),
|
||||
{ headers: { "Content-Type": "application/json" } }
|
||||
);
|
||||
}
|
||||
|
||||
candidates = [];
|
||||
|
||||
for (const couple of data) {
|
||||
if (!couple.user1_id || !couple.user2_id) continue;
|
||||
|
||||
// Count questions each user answered that the other hasn't
|
||||
for (const [userId, partnerId] of [
|
||||
[couple.user1_id, couple.user2_id],
|
||||
[couple.user2_id, couple.user1_id],
|
||||
] as [string, string][]) {
|
||||
const { count, error: countError } = await supabase
|
||||
.from("responses")
|
||||
.select("question_id", { count: "exact", head: true })
|
||||
.eq("user_id", partnerId)
|
||||
.not(
|
||||
"question_id",
|
||||
"in",
|
||||
`(SELECT question_id FROM responses WHERE user_id = '${userId}')`
|
||||
);
|
||||
|
||||
// The above subquery won't work via PostgREST, use a different approach
|
||||
// Get partner's answered question IDs, then count how many the user hasn't answered
|
||||
}
|
||||
}
|
||||
|
||||
// Better approach: use execute_sql-style raw query via RPC
|
||||
// Actually, let's use a simpler approach with two queries
|
||||
candidates = await findBehindUsers(supabase);
|
||||
} else {
|
||||
candidates = behindUsers || [];
|
||||
}
|
||||
|
||||
// Now process each behind user
|
||||
let sent = 0;
|
||||
let skipped = 0;
|
||||
|
||||
for (const candidate of candidates) {
|
||||
const { behind_user_id, partner_name, pending_count } = candidate;
|
||||
|
||||
// Get user profile
|
||||
const { data: profile } = await supabase
|
||||
.from("profiles")
|
||||
.select("id, push_token, last_active_at")
|
||||
.eq("id", behind_user_id)
|
||||
.maybeSingle();
|
||||
|
||||
if (!profile?.push_token) {
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Skip if active in last 6 hours
|
||||
if (profile.last_active_at && profile.last_active_at > sixHoursAgo.toISOString()) {
|
||||
skipped++;
|
||||
// Reset tracking since they're active
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check notification preferences
|
||||
const { data: prefs } = await supabase
|
||||
.from("notification_preferences")
|
||||
.select("catchup_reminders_enabled")
|
||||
.eq("user_id", behind_user_id)
|
||||
.maybeSingle();
|
||||
|
||||
if (prefs && prefs.catchup_reminders_enabled === false) {
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Get or create tracking record
|
||||
const { data: tracking } = await supabase
|
||||
.from("catchup_reminder_tracking")
|
||||
.select("*")
|
||||
.eq("user_id", behind_user_id)
|
||||
.maybeSingle();
|
||||
|
||||
const pendingSince = tracking?.pending_since
|
||||
? new Date(tracking.pending_since)
|
||||
: null;
|
||||
const lastReminderAt = tracking?.last_reminder_sent_at
|
||||
? new Date(tracking.last_reminder_sent_at)
|
||||
: null;
|
||||
const reminderCount = tracking?.reminder_count || 0;
|
||||
|
||||
// Upsert pending_since if not set
|
||||
if (!pendingSince) {
|
||||
await supabase.from("catchup_reminder_tracking").upsert({
|
||||
user_id: behind_user_id,
|
||||
pending_since: now.toISOString(),
|
||||
reminder_count: 0,
|
||||
});
|
||||
// Too early for first reminder (just started tracking)
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check escalation schedule
|
||||
if (pendingSince > twentyFourHoursAgo) {
|
||||
// Less than 24h since questions appeared — too early
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (reminderCount === 0) {
|
||||
// Send first reminder
|
||||
} else if (lastReminderAt && lastReminderAt > threeDaysAgo) {
|
||||
// Less than 3 days since last reminder — skip
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Determine notification copy
|
||||
const name = partner_name || "Your partner";
|
||||
let title: string;
|
||||
let body: string;
|
||||
|
||||
if (reminderCount === 0) {
|
||||
title = "Questions waiting";
|
||||
body = pending_count >= 6
|
||||
? `${name} has answered ${pending_count} questions you haven't seen yet`
|
||||
: `${name} answered some questions — your turn!`;
|
||||
} else {
|
||||
title = "Still waiting on you";
|
||||
body = `You have ${pending_count} questions waiting. Don't leave ${name} hanging!`;
|
||||
}
|
||||
|
||||
try {
|
||||
await sendExpoPushNotification({
|
||||
to: profile.push_token,
|
||||
title,
|
||||
body,
|
||||
sound: "default",
|
||||
data: { type: "catchup_reminder" },
|
||||
});
|
||||
|
||||
// Update tracking
|
||||
await supabase.from("catchup_reminder_tracking").upsert({
|
||||
user_id: behind_user_id,
|
||||
pending_since: tracking.pending_since,
|
||||
last_reminder_sent_at: now.toISOString(),
|
||||
reminder_count: reminderCount + 1,
|
||||
});
|
||||
|
||||
sent++;
|
||||
} catch (error) {
|
||||
console.error(`Failed to send catchup reminder to ${behind_user_id}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
// Reset tracking for users who have caught up
|
||||
await resetCaughtUpUsers(supabase);
|
||||
|
||||
console.log(`Catchup reminders: sent=${sent}, skipped=${skipped}`);
|
||||
|
||||
return new Response(
|
||||
JSON.stringify({ success: true, sent, skipped }),
|
||||
{ headers: { "Content-Type": "application/json" } }
|
||||
);
|
||||
} catch (err) {
|
||||
console.error("Catchup reminder error:", err);
|
||||
return new Response(
|
||||
JSON.stringify({ error: "Internal server error" }),
|
||||
{ status: 500, headers: { "Content-Type": "application/json" } }
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Find users who are "behind" — their partner answered questions they haven't.
|
||||
* Returns: { behind_user_id, partner_name, pending_count }[]
|
||||
*/
|
||||
async function findBehindUsers(
|
||||
supabase: ReturnType<typeof createClient>
|
||||
): Promise<Array<{ behind_user_id: string; partner_name: string; pending_count: number }>> {
|
||||
// Get all active couples
|
||||
const { data: couples, error } = await supabase
|
||||
.from("couples")
|
||||
.select("id, user1_id, user2_id");
|
||||
|
||||
if (error || !couples) return [];
|
||||
|
||||
const results: Array<{ behind_user_id: string; partner_name: string; pending_count: number }> = [];
|
||||
|
||||
for (const couple of couples) {
|
||||
if (!couple.user1_id || !couple.user2_id) continue;
|
||||
|
||||
// Get enabled pack IDs for this couple
|
||||
const { data: enabledPacks } = await supabase
|
||||
.from("couple_packs")
|
||||
.select("pack_id")
|
||||
.eq("couple_id", couple.id)
|
||||
.eq("is_enabled", true);
|
||||
|
||||
const packIds = (enabledPacks || []).map((p: { pack_id: string }) => p.pack_id);
|
||||
if (packIds.length === 0) continue;
|
||||
|
||||
// For each direction, count questions partner answered that user hasn't
|
||||
for (const [userId, partnerId] of [
|
||||
[couple.user1_id, couple.user2_id],
|
||||
[couple.user2_id, couple.user1_id],
|
||||
] as [string, string][]) {
|
||||
// Get partner's answered question IDs (in enabled packs)
|
||||
const { data: partnerResponses } = await supabase
|
||||
.from("responses")
|
||||
.select("question_id")
|
||||
.eq("user_id", partnerId);
|
||||
|
||||
if (!partnerResponses || partnerResponses.length === 0) continue;
|
||||
|
||||
const partnerQuestionIds = partnerResponses.map((r: { question_id: string }) => r.question_id);
|
||||
|
||||
// Get user's answered question IDs
|
||||
const { data: userResponses } = await supabase
|
||||
.from("responses")
|
||||
.select("question_id")
|
||||
.eq("user_id", userId);
|
||||
|
||||
const userQuestionIds = new Set(
|
||||
(userResponses || []).map((r: { question_id: string }) => r.question_id)
|
||||
);
|
||||
|
||||
// Count questions partner answered that user hasn't
|
||||
const pendingCount = partnerQuestionIds.filter(
|
||||
(qid: string) => !userQuestionIds.has(qid)
|
||||
).length;
|
||||
|
||||
if (pendingCount > 0) {
|
||||
// Get partner name
|
||||
const { data: partnerProfile } = await supabase
|
||||
.from("profiles")
|
||||
.select("name")
|
||||
.eq("id", partnerId)
|
||||
.maybeSingle();
|
||||
|
||||
results.push({
|
||||
behind_user_id: userId,
|
||||
partner_name: partnerProfile?.name || "Your partner",
|
||||
pending_count: pendingCount,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset tracking for users who have no pending questions (caught up).
|
||||
*/
|
||||
async function resetCaughtUpUsers(
|
||||
supabase: ReturnType<typeof createClient>
|
||||
): Promise<void> {
|
||||
// Get all tracked users
|
||||
const { data: tracked } = await supabase
|
||||
.from("catchup_reminder_tracking")
|
||||
.select("user_id")
|
||||
.not("pending_since", "is", null);
|
||||
|
||||
if (!tracked || tracked.length === 0) return;
|
||||
|
||||
for (const { user_id } of tracked) {
|
||||
// Get user's couple
|
||||
const { data: profile } = await supabase
|
||||
.from("profiles")
|
||||
.select("couple_id")
|
||||
.eq("id", user_id)
|
||||
.maybeSingle();
|
||||
|
||||
if (!profile?.couple_id) {
|
||||
// No couple — clear tracking
|
||||
await supabase
|
||||
.from("catchup_reminder_tracking")
|
||||
.update({ pending_since: null, reminder_count: 0 })
|
||||
.eq("user_id", user_id);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Get partner
|
||||
const { data: partner } = await supabase
|
||||
.from("profiles")
|
||||
.select("id")
|
||||
.eq("couple_id", profile.couple_id)
|
||||
.neq("id", user_id)
|
||||
.maybeSingle();
|
||||
|
||||
if (!partner) continue;
|
||||
|
||||
// Check if partner has any responses user hasn't answered
|
||||
const { data: partnerResponses } = await supabase
|
||||
.from("responses")
|
||||
.select("question_id")
|
||||
.eq("user_id", partner.id);
|
||||
|
||||
const { data: userResponses } = await supabase
|
||||
.from("responses")
|
||||
.select("question_id")
|
||||
.eq("user_id", user_id);
|
||||
|
||||
const userQuestionIds = new Set(
|
||||
(userResponses || []).map((r: { question_id: string }) => r.question_id)
|
||||
);
|
||||
|
||||
const pendingCount = (partnerResponses || []).filter(
|
||||
(r: { question_id: string }) => !userQuestionIds.has(r.question_id)
|
||||
).length;
|
||||
|
||||
if (pendingCount === 0) {
|
||||
await supabase
|
||||
.from("catchup_reminder_tracking")
|
||||
.update({ pending_since: null, reminder_count: 0, last_reminder_sent_at: null })
|
||||
.eq("user_id", user_id);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
import "jsr:@supabase/functions-js/edge-runtime.d.ts";
|
||||
import { createClient } from "https://esm.sh/@supabase/supabase-js@2.45.0?target=deno";
|
||||
|
||||
/**
|
||||
* Send push notification reminders to unpaired users.
|
||||
* Called by pg_cron daily at 18:00 UTC.
|
||||
*
|
||||
* Targets users where:
|
||||
* - couple_id IS NULL (no partner yet)
|
||||
* - push_token IS NOT NULL (can receive notifications)
|
||||
* - last_unpaired_reminder_at IS NULL or older than 3 days
|
||||
* - unpaired_reminders_enabled is not false in notification_preferences
|
||||
*/
|
||||
|
||||
const corsHeaders = {
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
"Access-Control-Allow-Headers": "authorization, x-client-info, apikey, content-type",
|
||||
};
|
||||
|
||||
const REMINDER_COOLDOWN_DAYS = 3;
|
||||
|
||||
interface ExpoPushMessage {
|
||||
to: string;
|
||||
title: string;
|
||||
body: string;
|
||||
sound?: string;
|
||||
data?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
async function sendExpoPushNotification(message: ExpoPushMessage) {
|
||||
const response = await fetch("https://exp.host/--/api/v2/push/send", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Accept: "application/json",
|
||||
},
|
||||
body: JSON.stringify(message),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.text();
|
||||
console.error("Expo push error:", error);
|
||||
throw new Error(`Failed to send push notification: ${error}`);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
Deno.serve(async (req) => {
|
||||
// Handle CORS preflight
|
||||
if (req.method === "OPTIONS") {
|
||||
return new Response("ok", { headers: corsHeaders });
|
||||
}
|
||||
|
||||
if (req.method !== "POST") {
|
||||
return new Response("Method not allowed", { status: 405, headers: corsHeaders });
|
||||
}
|
||||
|
||||
try {
|
||||
const supabase = createClient(
|
||||
Deno.env.get("SUPABASE_URL")!,
|
||||
Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!
|
||||
);
|
||||
|
||||
const now = new Date();
|
||||
const cutoff = new Date(now.getTime() - REMINDER_COOLDOWN_DAYS * 24 * 60 * 60 * 1000);
|
||||
|
||||
// Get unpaired users with push tokens who haven't been reminded recently
|
||||
const { data: users, error: usersError } = await supabase
|
||||
.from("profiles")
|
||||
.select("id, push_token, couple_id, last_unpaired_reminder_at")
|
||||
.is("couple_id", null)
|
||||
.not("push_token", "is", null)
|
||||
.or(`last_unpaired_reminder_at.is.null,last_unpaired_reminder_at.lt.${cutoff.toISOString()}`);
|
||||
|
||||
if (usersError) {
|
||||
console.error("Error fetching unpaired users:", usersError);
|
||||
return new Response(
|
||||
JSON.stringify({ error: "Failed to fetch users" }),
|
||||
{ status: 500, headers: { ...corsHeaders, "Content-Type": "application/json" } }
|
||||
);
|
||||
}
|
||||
|
||||
if (!users || users.length === 0) {
|
||||
return new Response(
|
||||
JSON.stringify({ success: true, sent: 0, message: "No eligible users" }),
|
||||
{ headers: { ...corsHeaders, "Content-Type": "application/json" } }
|
||||
);
|
||||
}
|
||||
|
||||
// Get notification preferences for these users to check opt-out
|
||||
const userIds = users.map((u) => u.id);
|
||||
const { data: prefs } = await supabase
|
||||
.from("notification_preferences")
|
||||
.select("user_id, unpaired_reminders_enabled")
|
||||
.in("user_id", userIds);
|
||||
|
||||
const disabledUsers = new Set(
|
||||
(prefs || [])
|
||||
.filter((p) => p.unpaired_reminders_enabled === false)
|
||||
.map((p) => p.user_id)
|
||||
);
|
||||
|
||||
// Check which users have a couple (invite code created but no partner yet)
|
||||
const { data: couples } = await supabase
|
||||
.from("couples")
|
||||
.select("id, user1_id")
|
||||
.in("user1_id", userIds);
|
||||
|
||||
const usersWithCouple = new Set(
|
||||
(couples || []).map((c) => c.user1_id)
|
||||
);
|
||||
|
||||
let sent = 0;
|
||||
let skipped = 0;
|
||||
|
||||
for (const user of users) {
|
||||
// Skip if user opted out
|
||||
if (disabledUsers.has(user.id)) {
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
const hasCouple = usersWithCouple.has(user.id);
|
||||
const title = "Sauci";
|
||||
const body = hasCouple
|
||||
? "Your invite code is waiting! Share it with your partner to start discovering what you have in common."
|
||||
: "Ready to get started? Create an invite code and share it with your partner!";
|
||||
|
||||
try {
|
||||
await sendExpoPushNotification({
|
||||
to: user.push_token,
|
||||
title,
|
||||
body,
|
||||
sound: "default",
|
||||
data: { type: "unpaired_reminder" },
|
||||
});
|
||||
|
||||
// Update last_unpaired_reminder_at
|
||||
await supabase
|
||||
.from("profiles")
|
||||
.update({ last_unpaired_reminder_at: now.toISOString() })
|
||||
.eq("id", user.id);
|
||||
|
||||
sent++;
|
||||
} catch (error) {
|
||||
console.error(`Failed to send reminder to ${user.id}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`Unpaired reminders: sent=${sent}, skipped=${skipped}, total=${users.length}`);
|
||||
|
||||
return new Response(
|
||||
JSON.stringify({ success: true, sent, skipped, total: users.length }),
|
||||
{ headers: { ...corsHeaders, "Content-Type": "application/json" } }
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("Unpaired reminder error:", error);
|
||||
return new Response(
|
||||
JSON.stringify({ error: "Internal server error" }),
|
||||
{ status: 500, headers: { ...corsHeaders, "Content-Type": "application/json" } }
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,213 @@
|
||||
import "jsr:@supabase/functions-js/edge-runtime.d.ts";
|
||||
import { createClient } from "https://esm.sh/@supabase/supabase-js@2.45.0?target=deno";
|
||||
|
||||
interface ExpoPushMessage {
|
||||
to: string;
|
||||
title: string;
|
||||
body: string;
|
||||
sound?: string;
|
||||
data?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
async function sendExpoPushNotifications(messages: ExpoPushMessage[]) {
|
||||
if (messages.length === 0) return;
|
||||
|
||||
const response = await fetch("https://exp.host/--/api/v2/push/send", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Accept: "application/json",
|
||||
},
|
||||
body: JSON.stringify(messages),
|
||||
});
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
function buildNotificationBody(matchCount: number): string {
|
||||
if (matchCount === 0) {
|
||||
return "No new matches this week. Answer some questions to discover what you have in common!";
|
||||
}
|
||||
if (matchCount <= 3) {
|
||||
return `You matched on ${matchCount} topic${matchCount === 1 ? "" : "s"} this week! Tap to start a conversation.`;
|
||||
}
|
||||
return `${matchCount} matches this week — you two are on a roll!`;
|
||||
}
|
||||
|
||||
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 oneWeekAgo = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000).toISOString();
|
||||
|
||||
// Get all couples that had at least one match this week
|
||||
const { data: recentMatches, error: matchError } = await supabase
|
||||
.from("matches")
|
||||
.select("couple_id")
|
||||
.gte("created_at", oneWeekAgo);
|
||||
|
||||
if (matchError) {
|
||||
console.error("Error fetching recent matches:", matchError);
|
||||
return new Response(
|
||||
JSON.stringify({ error: matchError.message }),
|
||||
{ status: 500, headers: { "Content-Type": "application/json" } }
|
||||
);
|
||||
}
|
||||
|
||||
// Get unique couple IDs that had matches
|
||||
const couplesWithMatches = new Set<string>(
|
||||
(recentMatches || []).map((m: { couple_id: string }) => m.couple_id)
|
||||
);
|
||||
|
||||
// Count matches per couple
|
||||
const matchCountByCouple = new Map<string, number>();
|
||||
for (const m of recentMatches || []) {
|
||||
matchCountByCouple.set(m.couple_id, (matchCountByCouple.get(m.couple_id) || 0) + 1);
|
||||
}
|
||||
|
||||
// Also get couples where users answered questions but got no matches
|
||||
const { data: recentResponses, error: responseError } = await supabase
|
||||
.from("responses")
|
||||
.select("user_id")
|
||||
.gte("created_at", oneWeekAgo);
|
||||
|
||||
if (responseError) {
|
||||
console.error("Error fetching recent responses:", responseError);
|
||||
return new Response(
|
||||
JSON.stringify({ error: responseError.message }),
|
||||
{ status: 500, headers: { "Content-Type": "application/json" } }
|
||||
);
|
||||
}
|
||||
|
||||
// Get couple_ids for users with responses
|
||||
const activeUserIds = [...new Set((recentResponses || []).map((r: { user_id: string }) => r.user_id))];
|
||||
|
||||
let activeCoupleIds = new Set<string>(couplesWithMatches);
|
||||
|
||||
if (activeUserIds.length > 0) {
|
||||
const { data: activeProfiles } = await supabase
|
||||
.from("profiles")
|
||||
.select("couple_id")
|
||||
.in("id", activeUserIds)
|
||||
.not("couple_id", "is", null);
|
||||
|
||||
for (const p of activeProfiles || []) {
|
||||
if (p.couple_id) activeCoupleIds.add(p.couple_id);
|
||||
}
|
||||
}
|
||||
|
||||
if (activeCoupleIds.size === 0) {
|
||||
return new Response(
|
||||
JSON.stringify({ sent: 0, message: "No active couples this week" }),
|
||||
{ headers: { "Content-Type": "application/json" } }
|
||||
);
|
||||
}
|
||||
|
||||
// Get all users in active couples with push tokens
|
||||
const { data: users, error: usersError } = await supabase
|
||||
.from("profiles")
|
||||
.select("id, couple_id, push_token")
|
||||
.in("couple_id", [...activeCoupleIds])
|
||||
.not("push_token", "is", null);
|
||||
|
||||
if (usersError) {
|
||||
console.error("Error fetching users:", usersError);
|
||||
return new Response(
|
||||
JSON.stringify({ error: usersError.message }),
|
||||
{ status: 500, headers: { "Content-Type": "application/json" } }
|
||||
);
|
||||
}
|
||||
|
||||
if (!users || users.length === 0) {
|
||||
return new Response(
|
||||
JSON.stringify({ sent: 0, message: "No users with push tokens" }),
|
||||
{ headers: { "Content-Type": "application/json" } }
|
||||
);
|
||||
}
|
||||
|
||||
// Check notification preferences for all users
|
||||
const userIds = users.map((u: { id: string }) => u.id);
|
||||
const { data: allPrefs } = await supabase
|
||||
.from("notification_preferences")
|
||||
.select("user_id, weekly_summary_enabled")
|
||||
.in("user_id", userIds);
|
||||
|
||||
const prefsMap = new Map<string, boolean>();
|
||||
for (const p of allPrefs || []) {
|
||||
prefsMap.set(p.user_id, p.weekly_summary_enabled);
|
||||
}
|
||||
|
||||
// Verify each user has a partner (couple has 2 members)
|
||||
const coupleUserCounts = new Map<string, number>();
|
||||
for (const u of users) {
|
||||
coupleUserCounts.set(u.couple_id, (coupleUserCounts.get(u.couple_id) || 0) + 1);
|
||||
}
|
||||
|
||||
const messages: ExpoPushMessage[] = [];
|
||||
let skippedPrefs = 0;
|
||||
let skippedNoPartner = 0;
|
||||
|
||||
for (const user of users) {
|
||||
// Skip if preference explicitly disabled
|
||||
if (prefsMap.get(user.id) === false) {
|
||||
skippedPrefs++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Skip if no partner in this couple (solo user)
|
||||
// Note: we only checked users with push tokens, partner may exist without token
|
||||
// So do a simpler check - just send to all eligible users in couples
|
||||
const matchCount = matchCountByCouple.get(user.couple_id) || 0;
|
||||
const body = buildNotificationBody(matchCount);
|
||||
|
||||
messages.push({
|
||||
to: user.push_token!,
|
||||
title: "Your weekly recap",
|
||||
body,
|
||||
sound: "default",
|
||||
data: { type: "weekly_summary" },
|
||||
});
|
||||
}
|
||||
|
||||
if (messages.length > 0) {
|
||||
// Batch in groups of 100 (Expo limit)
|
||||
for (let i = 0; i < messages.length; i += 100) {
|
||||
const batch = messages.slice(i, i + 100);
|
||||
await sendExpoPushNotifications(batch);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(
|
||||
`Sent ${messages.length} weekly summary notifications, skipped: prefs=${skippedPrefs}, no partner=${skippedNoPartner}`
|
||||
);
|
||||
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
sent: messages.length,
|
||||
skipped: { prefs: skippedPrefs, noPartner: skippedNoPartner },
|
||||
activeCouples: activeCoupleIds.size,
|
||||
}),
|
||||
{ headers: { "Content-Type": "application/json" } }
|
||||
);
|
||||
} catch (err) {
|
||||
console.error("Unexpected error:", err);
|
||||
return new Response(
|
||||
JSON.stringify({ error: "Internal server error" }),
|
||||
{ status: 500, headers: { "Content-Type": "application/json" } }
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,142 @@
|
||||
-- Helper function: sends a Discord notification via the send-discord-notification edge function
|
||||
CREATE OR REPLACE FUNCTION public.notify_discord_event(p_event text, p_payload jsonb)
|
||||
RETURNS void
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
AS $$
|
||||
BEGIN
|
||||
PERFORM net.http_post(
|
||||
url := 'https://ckjcrkjpmhqhiucifukx.supabase.co/functions/v1/send-discord-notification',
|
||||
headers := jsonb_build_object('Content-Type', 'application/json'),
|
||||
body := jsonb_build_object(
|
||||
'event', p_event,
|
||||
'payload', p_payload
|
||||
)
|
||||
);
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- Trigger function: new user signup
|
||||
CREATE OR REPLACE FUNCTION public.notify_new_user_discord()
|
||||
RETURNS trigger
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
AS $$
|
||||
BEGIN
|
||||
PERFORM public.notify_discord_event(
|
||||
'new_user',
|
||||
jsonb_build_object(
|
||||
'user_id', NEW.id,
|
||||
'name', NEW.name,
|
||||
'email', NEW.email,
|
||||
'created_at', NEW.created_at
|
||||
)
|
||||
);
|
||||
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- Trigger function: couple paired (fires when second partner joins)
|
||||
CREATE OR REPLACE FUNCTION public.notify_couple_paired_discord()
|
||||
RETURNS trigger
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
AS $$
|
||||
DECLARE
|
||||
v_member_count integer;
|
||||
v_profiles jsonb;
|
||||
BEGIN
|
||||
IF NEW.couple_id IS NULL OR OLD.couple_id IS NOT DISTINCT FROM NEW.couple_id THEN
|
||||
RETURN NEW;
|
||||
END IF;
|
||||
|
||||
SELECT COUNT(*)
|
||||
INTO v_member_count
|
||||
FROM public.profiles
|
||||
WHERE couple_id = NEW.couple_id;
|
||||
|
||||
IF v_member_count = 2 THEN
|
||||
SELECT jsonb_agg(
|
||||
jsonb_build_object(
|
||||
'id', p.id,
|
||||
'name', p.name,
|
||||
'email', p.email
|
||||
)
|
||||
ORDER BY p.created_at
|
||||
)
|
||||
INTO v_profiles
|
||||
FROM public.profiles p
|
||||
WHERE p.couple_id = NEW.couple_id;
|
||||
|
||||
PERFORM public.notify_discord_event(
|
||||
'couple_paired',
|
||||
jsonb_build_object(
|
||||
'couple_id', NEW.couple_id,
|
||||
'profiles', COALESCE(v_profiles, '[]'::jsonb),
|
||||
'paired_at', now()
|
||||
)
|
||||
);
|
||||
END IF;
|
||||
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- Trigger function: feedback submitted
|
||||
CREATE OR REPLACE FUNCTION public.notify_feedback_submitted_discord()
|
||||
RETURNS trigger
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
AS $$
|
||||
DECLARE
|
||||
v_user jsonb;
|
||||
BEGIN
|
||||
SELECT jsonb_build_object(
|
||||
'id', p.id,
|
||||
'name', p.name,
|
||||
'email', p.email
|
||||
)
|
||||
INTO v_user
|
||||
FROM public.profiles p
|
||||
WHERE p.id = NEW.user_id;
|
||||
|
||||
PERFORM public.notify_discord_event(
|
||||
'feedback_submitted',
|
||||
jsonb_build_object(
|
||||
'feedback_id', NEW.id,
|
||||
'user_id', NEW.user_id,
|
||||
'type', NEW.type,
|
||||
'title', NEW.title,
|
||||
'description', NEW.description,
|
||||
'status', NEW.status,
|
||||
'screenshot_url', NEW.screenshot_url,
|
||||
'question_id', NEW.question_id,
|
||||
'created_at', NEW.created_at,
|
||||
'user', v_user
|
||||
)
|
||||
);
|
||||
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- Create triggers (drop first to be idempotent)
|
||||
DROP TRIGGER IF EXISTS on_profile_created_discord ON public.profiles;
|
||||
CREATE TRIGGER on_profile_created_discord
|
||||
AFTER INSERT ON public.profiles
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION public.notify_new_user_discord();
|
||||
|
||||
DROP TRIGGER IF EXISTS on_profile_couple_paired_discord ON public.profiles;
|
||||
CREATE TRIGGER on_profile_couple_paired_discord
|
||||
AFTER UPDATE OF couple_id ON public.profiles
|
||||
FOR EACH ROW
|
||||
WHEN (OLD.couple_id IS DISTINCT FROM NEW.couple_id AND NEW.couple_id IS NOT NULL)
|
||||
EXECUTE FUNCTION public.notify_couple_paired_discord();
|
||||
|
||||
DROP TRIGGER IF EXISTS on_feedback_submitted_discord ON public.feedback;
|
||||
CREATE TRIGGER on_feedback_submitted_discord
|
||||
AFTER INSERT ON public.feedback
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION public.notify_feedback_submitted_discord();
|
||||
@@ -0,0 +1,16 @@
|
||||
-- Add weekly_summary_enabled to notification_preferences
|
||||
ALTER TABLE notification_preferences
|
||||
ADD COLUMN IF NOT EXISTS weekly_summary_enabled BOOLEAN DEFAULT true;
|
||||
|
||||
-- Schedule weekly summary notification: Sunday 10:00 UTC
|
||||
SELECT cron.schedule(
|
||||
'send-weekly-summary',
|
||||
'0 10 * * 0',
|
||||
$$
|
||||
SELECT net.http_post(
|
||||
url := 'https://ckjcrkjpmhqhiucifukx.supabase.co/functions/v1/send-weekly-summary',
|
||||
headers := jsonb_build_object('Content-Type', 'application/json'),
|
||||
body := '{}'::jsonb
|
||||
)
|
||||
$$
|
||||
);
|
||||
@@ -0,0 +1,20 @@
|
||||
-- Add tracking column for unpaired reminder rate limiting
|
||||
ALTER TABLE profiles
|
||||
ADD COLUMN IF NOT EXISTS last_unpaired_reminder_at TIMESTAMPTZ;
|
||||
|
||||
-- Add preference toggle for unpaired reminders
|
||||
ALTER TABLE notification_preferences
|
||||
ADD COLUMN IF NOT EXISTS unpaired_reminders_enabled BOOLEAN DEFAULT true;
|
||||
|
||||
-- Schedule daily cron job at 18:00 UTC to send unpaired reminders
|
||||
SELECT cron.schedule(
|
||||
'send-unpaired-reminders',
|
||||
'0 18 * * *',
|
||||
$$
|
||||
SELECT net.http_post(
|
||||
url := 'https://ckjcrkjpmhqhiucifukx.supabase.co/functions/v1/send-unpaired-reminder',
|
||||
headers := jsonb_build_object('Content-Type', 'application/json'),
|
||||
body := '{}'::jsonb
|
||||
)
|
||||
$$
|
||||
);
|
||||
@@ -0,0 +1,27 @@
|
||||
-- Catchup reminder tracking table
|
||||
CREATE TABLE IF NOT EXISTS catchup_reminder_tracking (
|
||||
user_id UUID PRIMARY KEY REFERENCES profiles(id) ON DELETE CASCADE,
|
||||
pending_since TIMESTAMPTZ,
|
||||
last_reminder_sent_at TIMESTAMPTZ,
|
||||
reminder_count INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
|
||||
-- Enable RLS (service role only)
|
||||
ALTER TABLE catchup_reminder_tracking ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
-- Add preference toggle for catchup reminders
|
||||
ALTER TABLE notification_preferences
|
||||
ADD COLUMN IF NOT EXISTS catchup_reminders_enabled BOOLEAN DEFAULT true;
|
||||
|
||||
-- Schedule daily cron job at 17:00 UTC to send catchup reminders
|
||||
SELECT cron.schedule(
|
||||
'send-catchup-reminders',
|
||||
'0 17 * * *',
|
||||
$$
|
||||
SELECT net.http_post(
|
||||
url := 'https://ckjcrkjpmhqhiucifukx.supabase.co/functions/v1/send-catchup-reminder',
|
||||
headers := jsonb_build_object('Content-Type', 'application/json'),
|
||||
body := '{}'::jsonb
|
||||
)
|
||||
$$
|
||||
);
|
||||
Reference in new issue
Block a user