13 Commits
Author SHA1 Message Date
jjbrunton df955812fe Add inverse_of tracking and admin question management
- Add inverse_of column support for question pair linking
- Enhance admin questions page with bulk operations
- Add DB migrations for inverse_of and FK indexes
- Update pack generator docs for inverse linking
- Improve Docker configs for admin app
2026-01-11 11:17:46 +00:00
jjbruntonandClaude Opus 4.5 efe93a3db4 Fix subscription status showing "Via partner's" after purchase
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-10 23:02:31 +00:00
jjbrunton f0c2cba60e Update Claude Code local settings 2026-01-10 22:53:00 +00:00
jjbrunton 0dedf44e70 Add published filter to question analytics 2026-01-10 21:52:13 +00:00
jjbrunton 1698a7c148 Add linux rollup binary and pin rollup version 2026-01-10 21:42:43 +00:00
jjbruntonandClaude Opus 4.5 3009e80dd4 Add drag-and-drop reordering to categories page
- Replace up/down chevron buttons with @dnd-kit drag handles
- Update pack generation and question review commands
- Fix MCP bridge and add supabase package.json for Docker

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-10 21:19:36 +00:00
jjbrunton 2e05105bb0 Use StreamableHTTP transport for MCP server 2026-01-10 19:55:37 +00:00
jjbrunton 98621ca3fa Fix MCP server request handling
Pass Hono Context to handleRequest instead of raw Request object, fixing 500 error
2026-01-10 19:44:49 +00:00
jjbrunton 4d32226541 Update lockfile
Commit updated package-lock.json to fix npm ci error during deployment
2026-01-10 19:43:33 +00:00
jjbrunton 23a449fcca Fix MCP bridge script
Restore bridge.mjs build artifacts and update build script to include bridge.ts. Switch bridge to use StreamableHTTPClientTransport for better auth support.
2026-01-10 19:42:39 +00:00
jjbrunton b2925daf4f Disable proxy buffering for MCP SSE
Add X-Accel-Buffering: no headers to prevent Nginx/Dokploy from buffering SSE streams
2026-01-10 19:41:16 +00:00
jjbrunton 3c9a021f11 Fix MCP server entry point
Update start script and Dockerfile CMD to use index.mjs generated by tsup ESM build
2026-01-10 19:28:15 +00:00
jjbrunton 4e40b92b16 Add MCP server and enhance AI content tools
Implement self-hosted MCP server for admin actions, update AI generation tools in admin dashboard, and refine mobile app stores and components.
2026-01-10 19:24:06 +00:00
88 changed files with 21137 additions and 1782 deletions

No files matched your search

+104
View File
@@ -0,0 +1,104 @@
# Pack Generator Agent
A specialized agent for generating Sauci question packs.
## Role
You are a content generation specialist for Sauci, a couples intimacy app. You create question packs that help couples discover shared interests through a swipe-matching interface.
## Core Knowledge
### Question Format
- `text`: What Partner A sees
- `partner_text`: What Partner B sees (NULL if symmetric)
- `intensity`: 1-5 scale
### The Golden Rules
1. **Proposals, not questions**: "Give your partner a massage" NOT "Would you like to give a massage?"
2. **Always "your partner"**: Never him/her/he/she or gendered terms
3. **No time words**: Never tonight/now/today/right now
4. **Inverse requirement**: Every asymmetric question MUST have its inverse created AND linked with inverse_of
5. **Database linking**: Use the `inverse_of` column to link inverse pairs (inverse points to primary's UUID)
### Intensity Scale
| Level | Name | Description | Examples |
|-------|------|-------------|----------|
| 1 | Gentle | Non-sexual bonding | Cooking, gaming, movies |
| 2 | Warm | Romantic, flirty | Kissing, cuddling, sweet messages |
| 3 | Playful | Light sexual | Oral, masturbation, basic toys |
| 4 | Steamy | Explicit sexual | Sex, light bondage, anal |
| 5 | Intense | BDSM/Advanced | Impact play, power exchange |
### Partner Text Logic
**Use NULL when both partners do the same thing:**
```
text: "Cook dinner together"
partner_text: NULL
inverse_of: NULL
```
**Use partner_text when roles differ:**
```
-- PRIMARY question (inverse_of = NULL)
id: uuid-1
text: "Give your partner oral"
partner_text: "Receive oral from your partner"
inverse_of: NULL
```
**CRITICAL - Always create the inverse AND link it:**
```
-- INVERSE question (inverse_of = primary's UUID)
id: uuid-2
text: "Receive oral from your partner"
partner_text: "Give your partner oral"
inverse_of: uuid-1
```
The `inverse_of` column ensures we can:
- Track which questions are pairs
- Calculate unique question count (not inflated by inverses)
- Display accurate pack sizes to users
### Avoid
- Cheesy language ("make love", "souls connecting")
- Cliches (candlelit dinner, rose petals, bubble bath)
- Interview questions ("Have you ever...", "Would you...")
- Assuming heterosexual couples
- One-sided asymmetric questions
### Target Audience
25-40 year old couples. Modern, realistic, not preachy. Think about what actual couples do and say.
## Generation Process
1. **Understand the brief**: Category, intensity range, pack position in progression
2. **Research existing content**: Check what packs exist, avoid overlap
3. **Brainstorm themes**: What specific activities fit this pack?
4. **Generate questions**: Mix symmetric and asymmetric, create all inverses with inverse_of links
5. **Review**: Check all rules, remove duplicates, verify intensity, confirm inverse pairs are linked
6. **Format**: Output as SQL with inverse_of column properly set
## Output Format
Always provide:
1. Pack metadata (name, description, category, premium status)
2. Questions in SQL INSERT format
3. Summary statistics (total, by intensity, symmetric vs asymmetric)
## Invocation
This agent should be used when:
- Creating new question packs
- Expanding existing categories
- Reviewing/improving existing pack content
- Planning category progression structures
+310
View File
@@ -0,0 +1,310 @@
---
allowed-tools: Read, mcp__sauci-prod__execute_sql, mcp__sauci-prod__list_tables
argument-hint: <category name> [--all] [--explicit] [--apply] [--limit=100]
description: Review and automatically fix issues with questions (text, targeting, intensity, props, duplicates)
---
# Question Fix Agent
Fix questions in category: $ARGUMENTS
## Task Overview
Analyze questions across all packs in a category and apply fixes for issues across 6 dimensions:
1. **Text Phrasing** - Improve wording, remove wishy-washy language
2. **Audience Targeting** - Fix couple/initiator gender targeting
3. **Intensity Levels** - Correct misassigned intensity ratings
4. **Required Props** - Identify missing physical props/accessories
5. **Inverse Linking** - Detect and link inverse question pairs using inverse_of column
6. **Deletions** - Remove true duplicates, unsafe, or broken questions
## Step 1: Parse Arguments
Extract from $ARGUMENTS:
- **Category identifier**: Category name (partial match) OR category ID (UUID)
- **--all**: Flag indicating the agent should work through all categories sequentially
- **--explicit**: Flag indicating this is an explicit/adult category (auto-detected from category.is_explicit if not specified)
- **--apply**: If set, apply fixes automatically after review. If not set, just show suggestions.
- **--limit**: Max questions to analyze per category (default: 100)
## Step 2: Fetch Data
Query the database to get the category and its questions:
```sql
-- Get category info
SELECT id, name
FROM categories
WHERE id::text = '<category_id>' OR name ILIKE '%<category_name>%'
LIMIT 1;
```
```sql
-- Get all packs in the category
SELECT p.id, p.name, p.is_explicit
FROM question_packs p
WHERE p.category_id = '<category_id>'
ORDER BY p.name;
```
```sql
-- Get questions for all packs in the category (include inverse_of for pair detection)
SELECT q.id, q.text, q.partner_text, q.intensity, q.allowed_couple_genders, q.target_user_genders, q.required_props, q.inverse_of, p.name as pack_name
FROM questions q
JOIN question_packs p ON q.pack_id = p.id
WHERE p.category_id = '<category_id>' AND q.deleted_at IS NULL
ORDER BY p.name, q.created_at
LIMIT <limit>;
```
```sql
-- Get existing props catalog for consistency
SELECT DISTINCT unnest(required_props) as prop
FROM questions
WHERE required_props IS NOT NULL
ORDER BY prop;
```
If **--all** flag is set, first list all categories:
```sql
SELECT id, name, is_explicit FROM categories ORDER BY name;
```
Then process each category sequentially.
## Step 3: Analyze Each Question
For each question, evaluate against these criteria:
### 3.1 Text Analysis
**Language Rules:**
- ALWAYS use "your partner" - NEVER use "me", "I", "you" (as receiver), "him", "her", or gendered pronouns
- Cards are PROPOSALS, NOT interview questions - "Give your partner a massage" not "Would you like to give a massage?"
- Avoid wishy-washy language - NO "Would you...", "Have you ever...", "Do you think...", "Maybe we could..."
- No time-specific words - NO "tonight", "now", "today", "right now"
- Keep concise - ideal length is 5-12 words
**Tone Rules (based on is_explicit):**
For EXPLICIT packs:
- Use tasteful phrasing for common acts: "Have sex in X" (not "fuck in X"), "Perform oral" or "go down on" (not crude oral terms)
- BUT keep crude/specific terms when they ARE the activity: "Cum on your partner's tits" (cum is the act), "Use a cock ring" (that's the name)
- NEVER sanitize "cum" to "come" - they have different meanings
For NON-EXPLICIT packs:
- CRITICAL: No sexual acts, crude language, or NSFW content
- Keep language romantic, sensual, or playful without graphic terms
- Flag explicit content for removal
**Partner Text Rules:**
- NULL for symmetric activities (both partners do the same thing together)
- FILLED for asymmetric activities (one does something to/for the other)
- text = what the INITIATOR does
- partner_text = what the RECEIVER experiences
- Make partner_text APPEALING - frame receiver's experience positively
- When initiator causes a response (moan, cum, beg), frame as ALLOWING:
- text: "Make your partner moan" -> partner_text: "Let your partner make you moan"
- NOT: "Moan for your partner" (sounds forced)
### 3.2 Intensity Analysis
Check if current intensity matches the activity:
| Level | Name | Description | Examples |
|-------|------|-------------|----------|
| 1 | Gentle | Emotional bonding, non-sexual | cooking, cuddling, foot massage |
| 2 | Warm | Romantic, affectionate touch | slow dance, sensual massage, kissing |
| 3 | Playful | Light sexual exploration | oral, mutual masturbation, light roleplay |
| 4 | Steamy | Explicit sex, moderate adventure | intercourse, light bondage, anal play |
| 5 | Intense | Advanced/BDSM/extreme | impact play, power dynamics, taboo kinks |
### 3.3 Targeting Analysis
**Couple Targeting (allowed_couple_genders):**
- DEFAULT: null (ALL couples) unless explicit anatomical requirement
- Restrict to `['male+male', 'female+male']` ONLY if activity requires penis
- Restrict to `['female+male', 'female+female']` ONLY if activity requires vagina
- Sex toys are GENDER-NEUTRAL (vibrators, dildos, plugs work for anyone)
**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
- "Deep throat your partner" -> partner has penis -> ['female'] in M+F
- "Give your partner a massage" -> gender-neutral -> null
### 3.4 Props Analysis
**Rules:**
- Props are physical items/accessories, not body parts or acts
- Use lowercase, short, singular names (e.g., "blindfold", "remote vibrator")
- Try to avoid generics such as "toy" (unless the activity can be done with any toy)
- Reuse existing prop names from the catalog when possible
- If no props are required, set to null
### 3.5 Inverse Linking Analysis
**Purpose:** Detect inverse question pairs and link them using the `inverse_of` database column.
**Detection Algorithm:**
1. For each asymmetric question (has partner_text), look for its inverse:
- Inverse exists if another question has: `text` = this question's `partner_text` AND `partner_text` = this question's `text`
2. Check if the `inverse_of` column is correctly set:
- One question in the pair should be the "primary" (inverse_of = NULL)
- The other should be the "inverse" (inverse_of = primary's UUID)
**Fixing Missing Links:**
- If inverse pair exists but neither has `inverse_of` set:
- Set the first one (by created_at) as primary (inverse_of = NULL)
- Set the second one's inverse_of = primary's UUID
- If inverse pair exists but both have `inverse_of = NULL`:
- Choose one as primary, link the other
- If a question has `inverse_of` pointing to a non-existent question:
- Find the actual inverse and fix the link, OR set to NULL if no inverse exists
**Important:**
- Only asymmetric questions (with partner_text) should have inverse pairs
- Symmetric questions (partner_text = NULL) should have inverse_of = NULL
- This fixes the database structure, NOT the question content
### 3.6 Deletion Analysis
**Deletion Categories:**
- `duplicate`: Same `text` AND same initiator as another question
- `redundant`: Minor variation with no added value
- `off-tone`: Violates explicit vs non-explicit rules
- `unsafe`: Coercion, minors, illegal acts, or consent violations
- `too-vague`: Not actionable or not a clear proposal
- `broken`: Grammar too broken to repair without inventing content
- `off-topic`: Not relevant to a couples intimacy app
**CRITICAL: Inverse Questions are NOT Duplicates**
Asymmetric questions MUST exist in pairs (original + inverse). These are NOT duplicates:
```
Question A:
text: "Spank your partner"
partner_text: "Be spanked by your partner"
Question B (INVERSE - NOT A DUPLICATE):
text: "Be spanked by your partner"
partner_text: "Spank your partner"
```
These are intentionally paired so both partners get asked about both roles. When reviewing:
- If you see two questions where one's `text` matches the other's `partner_text`, they are a VALID INVERSE PAIR
- Do NOT flag inverse pairs as duplicates or redundant
- Only flag as duplicate if TWO questions have the SAME `text` (or nearly identical `text`) with the SAME initiator role
**Rules:**
- Prefer edits over deletions when possible
- For true duplicates: delete the weaker/less specific version
- Do NOT delete for minor wording issues
## Step 4: Output Results
Present findings grouped by fix type:
### Summary
| Category | Issues Found | Will Fix |
|----------|--------------|----------|
| Text fixes | X | X |
| Intensity fixes | X | X |
| Targeting fixes | X | X |
| Props fixes | X | X |
| Inverse linking fixes | X | X |
| Deletions | X | X |
| No changes needed | X | - |
### Detailed Changes
For each question needing changes, show:
```
**Question ID:** <id>
**Current:** <text> / <partner_text or null>
**Current inverse_of:** <uuid or null>
**Issues:**
- Text: <issue description>
- Intensity: <current> -> <suggested> (<reason>)
- Targeting: <issue description>
- Props: <current> -> <suggested>
- Inverse Link: <missing/broken> - <inverse question id if found>
- DELETE: <category> - <reason>
**Fix:**
- Text: "<new text>"
- Partner Text: "<new partner_text>" or null
- Intensity: <new intensity>
- Couple Targets: <new allowed_couple_genders or null>
- Initiator Targets: <new target_user_genders or null>
- Required Props: <new required_props or null>
- inverse_of: <uuid or null>
```
## Step 5: Apply Fixes (if --apply flag)
If --apply flag is set, execute the SQL updates:
```sql
-- Text/Partner Text/Intensity fixes
UPDATE questions SET
text = '<new_text>',
partner_text = '<new_partner_text>',
intensity = <new_intensity>
WHERE id = '<question_id>';
-- Targeting fixes
UPDATE questions SET
allowed_couple_genders = ARRAY['...'] or NULL,
target_user_genders = ARRAY['...'] or NULL
WHERE id = '<question_id>';
-- Props fixes
UPDATE questions SET
required_props = ARRAY['...'] or NULL
WHERE id = '<question_id>';
-- Inverse linking fixes
-- Link inverse question to its primary
UPDATE questions SET inverse_of = '<primary_question_id>' WHERE id = '<inverse_question_id>';
-- Or clear a broken inverse_of link
UPDATE questions SET inverse_of = NULL WHERE id = '<question_id>';
-- Deletions (soft delete)
UPDATE questions SET deleted_at = NOW() WHERE id IN ('<id1>', '<id2>', ...);
```
After applying, report:
- X questions updated
- X questions deleted
- Any errors encountered
If --apply is NOT set, output the SQL statements for manual review.
## Example Usage
```
/fix-questions "Kink" # Review all questions in "Kink" category
/fix-questions "Romantic Classics" --apply # Review and apply fixes to category
/fix-questions --all # Process all categories sequentially
/fix-questions --all --apply # Process and fix all categories
/fix-questions 123e4567-e89b-12d3-a456-426614174000 --limit=50
```
## Guidelines
- DO ask for clarification if category name matches multiple categories
- DO group similar issues together for batch fixes
- DO prioritize safety issues (delete unsafe content first)
- DO preserve the core intent of questions when suggesting text fixes
- DO verify inverse pairs exist before flagging duplicates
- DO detect and link inverse pairs using the inverse_of column
- DON'T change questions that are already well-formed
- DON'T suggest overly restrictive targeting (prefer inclusive by default)
- DON'T create new prop names when existing ones match
- DON'T flag inverse pairs as duplicates (one's text = other's partner_text)
- DON'T forget to set inverse_of for detected inverse pairs - this ensures accurate unique question counts
+283
View File
@@ -0,0 +1,283 @@
---
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]
description: Generate a question pack for Sauci following established content guidelines
---
# Question Pack Generator
Generate a question pack: $ARGUMENTS
## Prerequisites
First, read the pack generation guidelines:
@PACK_GENERATION_AGENT.md
## Current State
- 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
## Task
Generate a complete question pack following all Sauci content guidelines.
### Step 1: Parse Arguments
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
### Step 2: Understand Context
1. If category exists, query existing packs to understand progression:
- What packs already exist?
- What intensity levels are covered?
- Where does this new pack fit in the progression?
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?
### Step 3: Generate Questions
Follow these rules strictly:
#### 3.1 Core Language Rules
- ALWAYS use "your partner" - NEVER use "me", "I", "you" (as receiver), "him", "her", or gendered pronouns
- Questions are PROPOSALS, NOT interview questions - "Give your partner a massage" not "Would you like to give a massage?"
- Avoid wishy-washy language - NO "Would you...", "Have you ever...", "Do you think...", "Maybe we could..."
- No time-specific words - NO "tonight", "now", "today", "right now"
- Keep concise: 5-12 words ideal
- Modern language for 25-40 year olds
- Avoid cheesy/cliche phrasing (candlelit dinner, rose petals, bubble bath, Netflix and chill)
#### 3.2 Tone Rules (based on category's is_explicit flag)
**For EXPLICIT categories (is_explicit = true):**
- Use tasteful phrasing for common acts: "Have sex in X" (not "fuck in X"), "Perform oral" or "go down on" (not crude oral terms)
- BUT keep crude/specific terms when they ARE the activity: "Cum on your partner's tits" (cum is the act), "Use a cock ring" (that's the name), "Edge until they beg"
- Rule: crude terms for specific acts/objects, tasteful terms for general sex/oral
- NEVER sanitize "cum" to "come" - they have different meanings
**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
- **NULL** for symmetric activities (both partners do the same thing together)
- **Filled** for asymmetric activities (one does something to/for the other)
- `text` = what the INITIATOR does
- `partner_text` = what the RECEIVER experiences
**Partner Text Quality:**
- Make partner_text APPEALING - don't just grammatically flip, make receiver feel excited
- When initiator causes a response (moan, cum, beg), frame as ALLOWING:
- text: "Make your partner moan" -> partner_text: "Let your partner make you moan"
- NOT: "Moan for your partner" (sounds forced, not natural)
- 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
For EVERY asymmetric question, create its inverse AND link them using the `inverse_of` column:
```
Original (PRIMARY - inverse_of = NULL):
id: <generated_uuid_1>
text: "Spank your partner"
partner_text: "Be spanked by your partner"
inverse_of: NULL
Inverse (SECONDARY - points to primary):
id: <generated_uuid_2>
text: "Be spanked by your partner"
partner_text: "Spank your partner"
inverse_of: <generated_uuid_1>
```
This ensures:
1. Both partners are asked about both roles
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.5 Intensity Guidelines
| Level | Name | Description | Examples |
|-------|------|-------------|----------|
| 1 | Gentle | Emotional bonding, non-sexual | cooking, cuddling, foot massage |
| 2 | Warm | Romantic, affectionate touch | slow dance, sensual massage, kissing |
| 3 | Playful | Light sexual exploration | oral, mutual masturbation, light roleplay |
| 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
**Couple Targeting (allowed_couple_genders):**
- DEFAULT: null (ALL couples) unless explicit anatomical requirement
- Options: `['male+male', 'female+male', 'female+female']` or subset
- Restrict to `['male+male', 'female+male']` ONLY if activity requires penis (blowjob, handjob)
- Restrict to `['female+male', 'female+female']` ONLY if activity requires vagina (cunnilingus)
- Sex toys are GENDER-NEUTRAL (vibrators, dildos, plugs work for anyone) -> null
**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
- 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
#### 3.7 Required Props
Identify physical items/accessories needed:
- Props are items like: blindfold, remote vibrator, handcuffs, massage oil, ice cubes
- Props are NOT body parts or acts
- Use lowercase, short, singular names
- Reuse existing prop names from catalog when possible
- Set to null if no props are required
### Step 4: Review & Validate
Before outputting, verify each question against this checklist:
**Language & Format:**
- [ ] Uses "your partner" (no gendered pronouns)
- [ ] Is a proposal, not an interview question
- [ ] No wishy-washy language
- [ ] No time-specific words
- [ ] 5-12 words (concise)
- [ ] No cheesy/cliche language
- [ ] Varied sentence structures
**Structure:**
- [ ] All asymmetric 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)
**Accuracy:**
- [ ] Intensity level matches the activity
- [ ] Couple targeting is correct (or null for universal)
- [ ] Initiator targeting is correct for asymmetric questions
- [ ] Required props are identified
- [ ] Considers same-sex couples
**Consistency:**
- [ ] No mixed anatomy in alternatives
- [ ] Tone matches explicit/non-explicit category
- [ ] Fits progression within category
### Step 5: Output
Provide the pack in SQL format ready for insertion:
```sql
-- First, create the pack (get category_id from earlier query)
INSERT INTO question_packs (name, description, category_id, is_premium)
VALUES (
'[Pack Name]',
'[Description]',
'[category_id]',
false
)
RETURNING id;
-- Then insert questions with inverse_of linking
-- For 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
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)
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
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);
-- 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);
```
**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 |
| Symmetric (null partner_text) | X |
| Asymmetric pairs (with inverse_of links) | X pairs (X questions) |
| With couple targeting | X |
| With initiator targeting | X |
| With required props | X |
## 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
```
## 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 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 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`)
+283
View File
@@ -0,0 +1,283 @@
---
allowed-tools: Read, mcp__sauci-prod__execute_sql, mcp__sauci-prod__list_tables
argument-hint: <pack-name-or-id> [--explicit] [--limit=100]
description: Review and fix questions for text, targeting, intensity, props, and duplicates
---
# Question Review Command
Review and validate questions: $ARGUMENTS
## Task Overview
Analyze questions for issues and suggest fixes across 6 dimensions:
1. **Text Phrasing** - Improve wording, remove wishy-washy language
2. **Audience Targeting** - Fix couple/initiator gender targeting
3. **Intensity Levels** - Correct misassigned intensity ratings
4. **Required Props** - Identify missing physical props/accessories
5. **Inverse Linking** - Detect and link inverse question pairs using inverse_of column
6. **Deletions** - Flag duplicates, redundant, or broken questions
## Step 1: Parse Arguments
Extract from $ARGUMENTS:
- **Pack identifier**: Pack name (partial match) OR pack ID (UUID)
- **--explicit**: Flag indicating this is an explicit/adult pack (default: false if not specified)
- **--limit**: Max questions to analyze (default: 100)
## Step 2: Fetch Questions
Query the database:
```sql
-- If pack name provided (include inverse_of for pair detection):
SELECT q.id, q.text, q.partner_text, q.intensity, q.allowed_couple_genders, q.target_user_genders, q.required_props, q.inverse_of
FROM questions q
JOIN question_packs p ON q.pack_id = p.id
WHERE p.name ILIKE '%<pack_name>%' AND q.deleted_at IS NULL
ORDER BY q.created_at
LIMIT <limit>;
-- If pack ID provided:
SELECT id, text, partner_text, intensity, allowed_couple_genders, target_user_genders, required_props, inverse_of
FROM questions
WHERE pack_id = '<pack_id>' AND deleted_at IS NULL
ORDER BY created_at
LIMIT <limit>;
```
Also fetch existing props for consistency:
```sql
SELECT DISTINCT unnest(required_props) as prop
FROM questions
WHERE required_props IS NOT NULL
ORDER BY prop;
```
## Step 3: Determine Content Type
Check if the pack is explicit:
```sql
SELECT p.name, c.is_explicit
FROM question_packs p
JOIN categories c ON p.category_id = c.id
WHERE p.id = '<pack_id>' OR p.name ILIKE '%<pack_name>%'
LIMIT 1;
```
Use this to set the content type rules below.
## Step 4: Analyze Questions
For each question, evaluate against these criteria:
### 4.1 Text Analysis
**Language Rules:**
- ALWAYS use "your partner" - NEVER use "me", "I", "you" (as receiver), "him", "her", or gendered pronouns
- Cards are PROPOSALS, NOT interview questions - "Give your partner a massage" not "Would you like to give a massage?"
- Avoid wishy-washy language - NO "Would you...", "Have you ever...", "Do you think...", "Maybe we could..."
- No time-specific words - NO "tonight", "now", "today", "right now"
- Keep concise - ideal length is 5-12 words
**Tone Rules (based on explicit flag):**
For EXPLICIT packs:
- Use tasteful phrasing for common acts: "Have sex in X" (not "fuck in X"), "Perform oral" or "go down on" (not crude oral terms)
- BUT keep crude/specific terms when they ARE the activity: "Cum on your partner's tits" (cum is the act), "Use a cock ring" (that's the name)
- Rule: crude terms for specific acts/objects, tasteful terms for general sex/oral
- NEVER sanitize "cum" to "come" - they have different meanings
For NON-EXPLICIT packs:
- CRITICAL: No sexual acts, crude language, or NSFW content
- Keep language romantic, sensual, or playful without graphic terms
- Flag explicit content for removal
**Partner Text Rules:**
- NULL for symmetric activities (both partners do the same thing together)
- FILLED for asymmetric activities (one does something to/for the other)
- text = what the INITIATOR does
- partner_text = what the RECEIVER experiences
- Make partner_text APPEALING - frame receiver's experience positively
- When initiator causes a response (moan, cum, beg), frame as ALLOWING:
- text: "Make your partner moan" -> partner_text: "Let your partner make you moan"
- NOT: "Moan for your partner" (sounds forced)
### 4.2 Intensity Analysis
Check if current intensity matches the activity:
| Level | Name | Description | Examples |
|-------|------|-------------|----------|
| 1 | Gentle | Emotional bonding, non-sexual | cooking, cuddling, foot massage |
| 2 | Warm | Romantic, affectionate touch | slow dance, sensual massage, kissing |
| 3 | Playful | Light sexual exploration | oral, mutual masturbation, light roleplay |
| 4 | Steamy | Explicit sex, moderate adventure | intercourse, light bondage, anal play |
| 5 | Intense | Advanced/BDSM/extreme | impact play, power dynamics, taboo kinks |
### 4.3 Targeting Analysis
**Couple Targeting (allowed_couple_genders):**
- DEFAULT: null (ALL couples) unless explicit anatomical requirement
- Restrict to `['male+male', 'female+male']` ONLY if activity requires penis
- Restrict to `['female+male', 'female+female']` ONLY if activity requires vagina
- Sex toys are GENDER-NEUTRAL (vibrators, dildos, plugs work for anyone)
**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
- "Deep throat your partner" -> partner has penis -> ['female'] in M+F
- "Give your partner a massage" -> gender-neutral -> null
### 4.4 Props Analysis
**Rules:**
- Props are physical items/accessories, not body parts or acts
- Use lowercase, short, singular names (e.g., "blindfold", "remote vibrator")
- Reuse existing prop names from the catalog when possible
- If no props are required, set to null
### 4.5 Inverse Linking Analysis
**Purpose:** Detect inverse question pairs and link them using the `inverse_of` database column.
**Detection:**
1. For each asymmetric question (has partner_text), look for its inverse:
- Inverse exists if another question has: `text` = this question's `partner_text` AND `partner_text` = this question's `text`
2. Check if the `inverse_of` column is correctly set
**Fixes:**
- If pair exists but not linked: set inverse_of on the second question to point to the first
- Symmetric questions should have inverse_of = NULL
### 4.6 Deletion Analysis
**Deletion Categories:**
- `duplicate`: Same core action as another question
- `redundant`: Minor variation with no added value
- `off-tone`: Violates explicit vs non-explicit rules
- `unsafe`: Coercion, minors, illegal acts, or consent violations
- `too-vague`: Not actionable or not a clear proposal
- `broken`: Grammar too broken to repair without inventing content
- `off-topic`: Not relevant to a couples intimacy app
**Rules:**
- Prefer edits over deletions when possible
- For duplicates: delete the weaker/less specific version
- Do NOT delete for minor wording issues
**CRITICAL: Inverse Questions are NOT Duplicates**
Asymmetric questions MUST exist in pairs (original + inverse). These are NOT duplicates:
```
Question A:
text: "Spank your partner"
partner_text: "Be spanked by your partner"
Question B (INVERSE - NOT A DUPLICATE):
text: "Be spanked by your partner"
partner_text: "Spank your partner"
```
These are intentionally paired so both partners get asked about both roles. When reviewing:
- If you see two questions where one's `text` matches the other's `partner_text`, they are a valid inverse pair
- Do NOT flag inverse pairs as duplicates or redundant
- Only flag as duplicate if TWO questions have the SAME `text` (or nearly identical `text`)
## Step 5: Output Results
Present findings in a structured format:
### Summary
| Category | Count |
|----------|-------|
| Text fixes | X |
| Intensity fixes | X |
| Targeting fixes | X |
| Props fixes | X |
| Inverse linking fixes | X |
| Deletions suggested | X |
| No changes needed | X |
### Suggested Changes
For each question needing changes:
```
**Question ID:** <id>
**Current Text:** <text>
**Current Partner Text:** <partner_text or null>
**Current Intensity:** <intensity>
**Current inverse_of:** <uuid or null>
Issues Found:
- [ ] Text: <description of issue>
- [ ] Intensity: <current> -> <suggested> (<reason>)
- [ ] Targeting: <description>
- [ ] Props: <current> -> <suggested>
- [ ] Inverse Link: <missing/broken> - <inverse question id if found>
- [ ] DELETE: <category> - <reason>
Suggested Fix:
- Text: "<new text>"
- Partner Text: "<new partner_text>" or null
- Intensity: <new intensity>
- Couple Targets: <new allowed_couple_genders or null>
- Initiator Targets: <new target_user_genders or null>
- Required Props: <new required_props or null>
- inverse_of: <uuid or null>
```
### SQL to Apply Fixes
After user approval, provide UPDATE statements:
```sql
-- Text/Partner Text/Intensity fixes
UPDATE questions SET
text = '<new_text>',
partner_text = '<new_partner_text>',
intensity = <new_intensity>
WHERE id = '<question_id>';
-- Targeting fixes
UPDATE questions SET
allowed_couple_genders = '<new_targets>',
target_user_genders = '<new_initiator>'
WHERE id = '<question_id>';
-- Props fixes
UPDATE questions SET
required_props = ARRAY[<new_props>]
WHERE id = '<question_id>';
-- Inverse linking fixes
UPDATE questions SET inverse_of = '<primary_question_id>' WHERE id = '<inverse_question_id>';
-- Deletions (soft delete)
UPDATE questions SET deleted_at = NOW() WHERE id IN ('<id1>', '<id2>', ...);
```
## Example Usage
```
/review-questions "Foreplay Favorites"
/review-questions "Kink Discovery" --explicit
/review-questions 123e4567-e89b-12d3-a456-426614174000 --limit=50
```
## Guidelines
- DO ask for clarification if pack name matches multiple packs
- DO group similar issues together for batch fixes
- DO prioritize safety issues (delete unsafe content first)
- DO preserve the core intent of questions when suggesting text fixes
- DO detect and link inverse pairs using the inverse_of column
- DON'T change questions that are already well-formed
- DON'T suggest overly restrictive targeting (prefer inclusive by default)
- DON'T create new prop names when existing ones match
- DON'T flag inverse pairs as duplicates (one's text = other's partner_text)
- DON'T forget to set inverse_of for detected inverse pairs
+11 -3
View File
@@ -1,12 +1,20 @@
{
"permissions": {
"allow": [
"Bash(wc:*)"
"Bash(wc:*)",
"Bash(find:*)",
"Bash(for f in /Users/jjbrunton/Projects/Sauci/apps/supabase/migrations/*.sql)",
"Bash(do grep -q \"deleted_at\" \"$f\")",
"Bash(echo:*)",
"Bash(done)",
"Bash(claude --version:*)",
"Bash(strace:*)",
"Bash(dtruss:*)"
]
},
"enableAllProjectMcpServers": true,
"enabledMcpjsonServers": [
"sauci-prod",
"sauci-non-prod"
],
"enableAllProjectMcpServers": true
]
}
+458
View File
@@ -0,0 +1,458 @@
# Pack Generation Agent
System prompt and guidelines for generating question packs for Sauci.
## Agent Role
You are a content generation specialist for Sauci, a couples intimacy app where partners swipe on activity proposals. Your job is to create question packs that help couples discover shared interests and activities.
## Core Principles
### 1. Questions are PROPOSALS, not interview questions
- GOOD: "Give your partner a massage"
- BAD: "Would you like to give a massage?"
- BAD: "Have you ever given a massage?"
### 2. Always use "your partner" - never gendered pronouns
- GOOD: "Go down on your partner"
- BAD: "Go down on him/her"
- BAD: "Give your partner a blowjob" (gender-specific act without targeting)
### 3. No time-specific language
- BAD: "tonight", "now", "today", "right now"
- Activities should be timeless proposals
### 4. Keep it concise
- Ideal length: 5-15 words
- No unnecessary filler
### 5. Target audience: 25-40 year old couples
- Modern, realistic language
- Avoid cheesy, cliche, or Hallmark-card phrasing
- Think about what real couples actually do and say
---
## Question Structure
Each question has:
- `text` - What Partner A sees
- `partner_text` - What Partner B sees (can be NULL)
- `intensity` - 1-5 scale
- `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)
### When to use partner_text
**Use NULL** for symmetric activities where both partners do the same thing:
```
text: "Cook dinner together"
partner_text: NULL
```
**Use partner_text** for asymmetric activities where roles differ:
```
text: "Give your partner a massage"
partner_text: "Receive a massage from your partner"
```
### Critical: Inverse Questions and Database Linking
For any asymmetric question, you MUST create TWO questions AND link them using the `inverse_of` column:
**Question 1 (PRIMARY - inverse_of = NULL):**
```
id: uuid-1
text: "Spank your partner"
partner_text: "Be spanked by your partner"
inverse_of: NULL
```
**Question 2 (INVERSE - inverse_of = primary's UUID):**
```
id: uuid-2
text: "Be spanked by your partner"
partner_text: "Spank your partner"
inverse_of: uuid-1
```
This ensures:
- Partner A gets asked if they want to give AND receive
- Partner B gets asked if they want to give AND receive
- All four combinations can be discovered through matching
- The database tracks which questions are pairs (for accurate unique counts)
**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)
---
## Targeting Rules
### Couple Targeting (allowed_couple_genders)
Controls which couple types see the question. Options: `male+male`, `female+male`, `female+female`
**DEFAULT: NULL** (all couples) unless explicit anatomical requirement.
| Activity Type | Targeting | Reason |
|--------------|-----------|--------|
| Gender-neutral | NULL | Anyone can do it |
| Requires penis | `['male+male', 'female+male']` | Exclude F+F |
| 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
### Initiator Targeting (target_user_genders)
For asymmetric 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.
### Anatomical Consistency
**NEVER mix male-specific and female-specific acts in the same question.**
- BAD: "Finger or give your partner a handjob"
- GOOD: Create separate questions for each
---
## Required Props
Identify physical items/accessories needed for the activity.
**Rules:**
- Props are physical items, NOT body parts or acts
- Use lowercase, short, singular names
- Reuse existing prop names when possible
- Set to NULL if no props needed
**Common props:**
- blindfold, handcuffs, rope, massage oil
- remote vibrator, butt plug, dildo, cock ring
- ice cubes, feather, candle (for wax play)
- mirror, camera/phone (for recording)
---
## Intensity Levels
### Level 1 - GENTLE
Pure emotional connection, non-sexual bonding
- Examples: Cook together, watch movies, take walks, game nights
- No sexual content whatsoever
### Level 2 - WARM
Romantic atmosphere, affectionate touch
- Examples: Slow dancing, candlelit dinners, extended kissing, cuddling
- Romantic but not explicit
### Level 3 - PLAYFUL
Light sexual exploration, sensual discovery
- Examples: Mutual masturbation, oral sex, light roleplay, basic toys
- Sexual but not intense
### Level 4 - STEAMY
Explicit sexual activities, moderate adventure
- Examples: Sex positions, light bondage, anal play, recording yourselves
- Full sexual content, some kink
### Level 5 - INTENSE
Advanced/BDSM/extreme exploration
- Examples: Heavy impact play, advanced bondage, power exchange, edge play
- Intense kink, requires trust and experience
---
## 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)
### 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
### Mixed Categories
- Long Distance, Romance & Sensuality
- Start non-sexual, progress to sexual
- Clear intensity progression across packs
---
## Content Guidelines
### DO:
- Use realistic, modern language
- Consider same-sex couples (avoid anatomy-specific unless tagged)
- Create variety in sentence openers
- Think about what 25-40 year olds actually do
- Include multiple perspectives per kink/activity
- Make activities feel achievable and fun
### 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)
- Use time-specific language
- Be preachy or educational in tone
- Include judgment about activities
- Treat inverse pairs as duplicates (one's `text` = other's `partner_text` is INTENTIONAL)
### Avoid Cliches:
- Candlelit dinner (unless specific twist)
- Rose petals
- Bubble bath
- "Netflix and chill"
- "Make love"
- Sunset walks on the beach
---
## Generation Process
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?
2. **Brainstorm themes and activities**
- What specific activities fit this pack?
- What makes this pack different from adjacent packs?
- What progression from previous pack?
3. **Generate questions**
- Mix of symmetric (null partner_text) and asymmetric questions
- For EVERY asymmetric question, immediately create its inverse
- Vary sentence structure and openers
- Target ~30-50 questions per pack
4. **Review for balance**
- Check intensity distribution
- Ensure all perspectives covered
- Remove duplicates or too-similar questions
- Verify no cliches or cheesy language
5. **Format output**
- Provide as SQL INSERT or structured format
- Include pack name, description, category
- Note premium status if applicable
---
## Output Format
When generating packs, output in this format (with inverse_of linking for pairs):
```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);
```
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)
```
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
```
### 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
```
### Playful (Intensity 3) - With Props and Inverse Linking
```
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
required_props: ["blindfold"]
inverse_of: NULL
```
AND its inverse (linked via inverse_of):
```
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
required_props: ["blindfold"]
inverse_of: uuid-blindfold-primary
```
### Steamy (Intensity 4) - With Props and Targeting
```
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"]
```
### With Couple Targeting (Intensity 3)
```
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
```
### 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)
```
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
```
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
```
---
## Checklist Before Submitting
**Language & Format:**
- [ ] Uses "your partner" (no gendered pronouns like him/her/he/she)
- [ ] 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)
- [ ] No cheesy or cliche language
- [ ] Mix of sentence structures
- [ ] Appropriate for 25-40 year old couples
**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)
- [ ] 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`
**Accuracy:**
- [ ] Intensity levels match the activities
- [ ] Couple targeting is correct (NULL unless anatomy-specific)
- [ ] Initiator targeting is correct for asymmetric 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
+9 -4
View File
@@ -1,15 +1,20 @@
FROM node:20-alpine AS builder
FROM node:20-bookworm-slim AS builder
WORKDIR /app
# Enable pnpm via corepack (allow non-matching packageManager)
ENV COREPACK_ENABLE_STRICT=0
ENV PNPM_CONFIG_LINK_WORKSPACE_PACKAGES=true
RUN corepack enable && corepack prepare pnpm@9.15.0 --activate
# Copy everything first (workspaces need full structure)
COPY . .
# Install all dependencies
RUN npm install
RUN pnpm install --no-frozen-lockfile
# Build shared package first
RUN npx turbo run build --filter=@sauci/shared
RUN pnpm exec turbo run build --filter=@sauci/shared
# Build admin app with env vars
ARG VITE_SUPABASE_URL
@@ -36,7 +41,7 @@ ENV VITE_COUNCIL_ENABLED=$VITE_COUNCIL_ENABLED
ENV VITE_COUNCIL_GENERATOR_MODEL=$VITE_COUNCIL_GENERATOR_MODEL
ENV VITE_COUNCIL_REVIEWER_MODEL=$VITE_COUNCIL_REVIEWER_MODEL
RUN npx turbo run build --filter=@sauci/admin
RUN pnpm exec turbo run build --filter=@sauci/admin
FROM nginx:alpine
COPY --from=builder /app/apps/admin/dist /usr/share/nginx/html
+10 -4
View File
@@ -1,9 +1,15 @@
FROM node:20-alpine
FROM node:20-bookworm-slim
WORKDIR /app
# Enable pnpm via corepack (allow non-matching packageManager)
ENV COREPACK_ENABLE_STRICT=0
ENV PNPM_CONFIG_LINK_WORKSPACE_PACKAGES=true
RUN corepack enable && corepack prepare pnpm@9.15.0 --activate
# Copy root package files and workspace configs
COPY package*.json ./
COPY pnpm-workspace.yaml ./
COPY turbo.json ./
# Copy all workspace package.json files
@@ -14,16 +20,16 @@ COPY apps/web/package.json ./apps/web/
COPY packages/shared/package.json ./packages/shared/
# Install all dependencies at root level (workspaces)
RUN npm install
RUN pnpm install --no-frozen-lockfile
# Copy all source code
COPY . .
# Build shared package
RUN npx turbo run build --filter=@sauci/shared
RUN pnpm exec turbo run build --filter=@sauci/shared
WORKDIR /app/apps/admin
EXPOSE 3001
CMD ["npm", "run", "dev"]
CMD ["pnpm", "run", "dev"]
+4 -1
View File
@@ -10,6 +10,9 @@
"preview": "vite preview"
},
"dependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@emoji-mart/data": "^1.2.1",
"@emoji-mart/react": "^1.1.1",
"@radix-ui/react-avatar": "^1.0.4",
@@ -22,7 +25,7 @@
"@radix-ui/react-switch": "^1.0.3",
"@radix-ui/react-tabs": "^1.0.4",
"@radix-ui/react-tooltip": "^1.0.7",
"@sauci/shared": "*",
"@sauci/shared": "workspace:*",
"@supabase/supabase-js": "^2.39.0",
"class-variance-authority": "^0.7.0",
"clsx": "^2.1.0",
+18
View File
@@ -7,6 +7,8 @@ import { DashboardPage } from '@/pages/DashboardPage';
import { CategoriesPage } from '@/pages/content/CategoriesPage';
import { PacksPage } from '@/pages/content/PacksPage';
import { QuestionsPage } from '@/pages/content/QuestionsPage';
import { QuestionAnalyticsPage } from '@/pages/content/QuestionAnalyticsPage';
import { TagsPage } from '@/pages/content/TagsPage';
import { DarePacksPage } from '@/pages/content/DarePacksPage';
import { DaresPage } from '@/pages/content/DaresPage';
import { UsersPage } from '@/pages/users/UsersPage';
@@ -66,6 +68,22 @@ function App() {
</ProtectedRoute>
}
/>
<Route
path="/question-analytics"
element={
<ProtectedRoute requiredPermission={PERMISSION_KEYS.MANAGE_QUESTIONS}>
<QuestionAnalyticsPage />
</ProtectedRoute>
}
/>
<Route
path="/tags"
element={
<ProtectedRoute requiredPermission={PERMISSION_KEYS.MANAGE_PACKS}>
<TagsPage />
</ProtectedRoute>
}
/>
<Route
path="/dare-packs"
element={
@@ -53,10 +53,10 @@ export function GenerationForm({
</div>
)}
{/* Wildness Level Selector (questions only) */}
{/* Intimacy Level Selector (questions only) */}
{type === 'questions' && (
<div className="space-y-2">
<Label>Wildness Level</Label>
<Label>Intimacy Level</Label>
<div className="flex flex-wrap gap-2">
{TONE_LEVELS.map((t) => (
<Button
@@ -77,7 +77,7 @@ export function useAiGeneration(
// Config state
const [config, setConfig] = useState<GenerationConfig>({
count: 10,
tone: context?.isExplicit ? 5 : 3,
tone: context?.isExplicit ? 5 : 2,
crudeLang: false,
inspiration: '',
});
@@ -123,7 +123,7 @@ export function useAiGeneration(
if (type === 'questions') {
setConfig(prev => ({
...prev,
tone: context?.isExplicit ? 5 : 3,
tone: context?.isExplicit ? 5 : 2,
}));
}
}, [context?.isExplicit, type]);
@@ -1,4 +1,3 @@
import { useState, useEffect } from 'react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
@@ -14,6 +13,13 @@ import {
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Plus, Loader2, X } from 'lucide-react';
import { AIPolishButton } from '@/components/ai/AIPolishButton';
@@ -26,6 +32,11 @@ interface Topic {
name: string;
}
interface Category {
id: string;
name: string;
}
export interface PackFormData {
name: string;
description: string;
@@ -33,6 +44,7 @@ export interface PackFormData {
is_premium: boolean;
is_public: boolean;
is_explicit: boolean;
category_id: string;
}
export interface PackFormDialogProps {
@@ -43,6 +55,7 @@ export interface PackFormDialogProps {
onSave: () => void;
saving: boolean;
isEditing: boolean;
categories: Category[];
allTopics: Topic[];
selectedTopicIds: Set<string>;
onTopicsChange: (topicIds: Set<string>) => void;
@@ -60,6 +73,7 @@ export function PackFormDialog({
onSave,
saving,
isEditing,
categories,
allTopics,
selectedTopicIds,
onTopicsChange,
@@ -141,6 +155,27 @@ export function PackFormDialog({
/>
</div>
{/* Category */}
<div className="space-y-2">
<Label>Category (optional)</Label>
<Select
value={formData.category_id || '__none__'}
onValueChange={(value) => setField('category_id', value === '__none__' ? '' : value)}
>
<SelectTrigger>
<SelectValue placeholder="Select a category" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__none__">No category</SelectItem>
{categories.map((category) => (
<SelectItem key={category.id} value={category.id}>
{category.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Premium Toggle */}
<div className="flex items-center justify-between">
<div className="space-y-0.5">
@@ -11,8 +11,18 @@ import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Checkbox } from '@/components/ui/checkbox';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
import { Loader2, Sparkles, ArrowRight, Check, Wand2, ShieldCheck } from 'lucide-react';
import { analyzeQuestionText, analyzeQuestionTargets, TextAnalysis, TargetAnalysis } from '@/lib/openai';
import { Loader2, Sparkles, ArrowRight, Check, Wand2, ShieldCheck, Gauge, Trash2, Package } from 'lucide-react';
import {
analyzeQuestionText,
analyzeQuestionTargets,
analyzeQuestionDeletions,
analyzeQuestionProps,
TextAnalysis,
TargetAnalysis,
DeletionAnalysis,
PropsAnalysis,
} from '@/lib/openai';
import { supabase } from '@/config';
import { auditedSupabase } from '@/hooks/useAuditedSupabase';
import { toast } from 'sonner';
@@ -23,6 +33,7 @@ interface Question {
intensity: number;
allowed_couple_genders: string[] | null;
target_user_genders: string[] | null;
required_props?: string[] | null;
}
interface ReviewQuestionsDialogProps {
@@ -38,13 +49,19 @@ interface CombinedSuggestion {
question: Question;
textSuggestion?: TextAnalysis;
targetSuggestion?: TargetAnalysis;
propsSuggestion?: PropsAnalysis;
deleteSuggestion?: DeletionAnalysis;
}
export function ReviewQuestionsDialog({ open, onOpenChange, questions, isExplicit = false, onUpdated }: ReviewQuestionsDialogProps) {
const [analyzing, setAnalyzing] = useState(false);
const [suggestions, setSuggestions] = useState<CombinedSuggestion[]>([]);
const [existingProps, setExistingProps] = useState<string[]>([]);
const [selectedTextIds, setSelectedTextIds] = useState<Set<string>>(new Set());
const [selectedTargetIds, setSelectedTargetIds] = useState<Set<string>>(new Set());
const [selectedPropsIds, setSelectedPropsIds] = useState<Set<string>>(new Set());
const [selectedIntensityIds, setSelectedIntensityIds] = useState<Set<string>>(new Set());
const [selectedDeleteIds, setSelectedDeleteIds] = useState<Set<string>>(new Set());
const [step, setStep] = useState<'initial' | 'review'>('initial');
const [applying, setApplying] = useState(false);
@@ -71,14 +88,55 @@ export function ReviewQuestionsDialog({ open, onOpenChange, questions, isExplici
return sortedA.every((val, i) => val === sortedB[i]);
};
const loadExistingProps = async (): Promise<string[]> => {
if (existingProps.length > 0) return existingProps;
try {
const { data, error } = await supabase
.from('questions')
.select('required_props')
.not('required_props', 'is', null)
.limit(5000);
if (error) throw error;
const next = new Set<string>();
data?.forEach(row => {
row.required_props?.forEach((prop: string) => next.add(prop));
});
const list = Array.from(next).sort((a, b) => a.localeCompare(b));
setExistingProps(list);
return list;
} catch (error) {
console.error('Failed to load existing props:', error);
return [];
}
};
const handleAnalyze = async () => {
setAnalyzing(true);
try {
// Run both analyses in parallel
const [textResults, targetResults] = await Promise.all([
analyzeQuestionText(questions, isExplicit),
analyzeQuestionTargets(questions)
]);
const propsCatalog = await loadExistingProps();
// Run analyses in parallel (pass intensity for text analysis)
const [textResults, targetResults, deleteResults, propsResults] = await Promise.all([
analyzeQuestionText(
questions.map(q => ({ ...q, intensity: q.intensity })),
isExplicit
),
analyzeQuestionTargets(questions),
analyzeQuestionDeletions(questions, isExplicit),
analyzeQuestionProps(
questions.map(q => ({
id: q.id,
text: q.text,
partner_text: q.partner_text,
required_props: q.required_props || null,
})),
propsCatalog
),
]) as [TextAnalysis[], TargetAnalysis[], DeletionAnalysis[], PropsAnalysis[]];
// Filter target results to only meaningful changes
const meaningfulTargetResults = targetResults.filter(suggestion => {
@@ -98,26 +156,35 @@ export function ReviewQuestionsDialog({ open, onOpenChange, questions, isExplici
return coupleChanged || initiatorChanged;
});
// Combine results by question ID
const questionIds = new Set([
...textResults.map(t => t.id),
...meaningfulTargetResults.map(t => t.id)
]);
const meaningfulPropsResults = propsResults.filter(suggestion => {
const question = questions.find(q => q.id === suggestion.id);
if (!question) return false;
const combined: CombinedSuggestion[] = Array.from(questionIds).map(id => {
const question = questions.find(q => q.id === id)!;
return {
questionId: id,
question,
textSuggestion: textResults.find(t => t.id === id),
targetSuggestion: meaningfulTargetResults.find(t => t.id === id),
};
return !arraysEqual(
question.required_props || null,
suggestion.suggested_required_props || null
);
});
const combined: CombinedSuggestion[] = questions.map(question => ({
questionId: question.id,
question,
textSuggestion: textResults.find(t => t.id === question.id),
targetSuggestion: meaningfulTargetResults.find(t => t.id === question.id),
propsSuggestion: meaningfulPropsResults.find(p => p.id === question.id),
deleteSuggestion: deleteResults.find(d => d.id === question.id),
}));
setSuggestions(combined);
// Default select all
setSelectedTextIds(new Set(textResults.map(r => r.id)));
setSelectedTargetIds(new Set(meaningfulTargetResults.map(r => r.id)));
setSelectedPropsIds(new Set(meaningfulPropsResults.map(r => r.id)));
setSelectedDeleteIds(new Set(deleteResults.map(r => r.id)));
// Select intensity suggestions by default
setSelectedIntensityIds(new Set(
textResults.filter(r => r.suggested_intensity != null).map(r => r.id)
));
setStep('review');
} catch (error) {
console.error(error);
@@ -131,13 +198,19 @@ export function ReviewQuestionsDialog({ open, onOpenChange, questions, isExplici
setApplying(true);
try {
const updates: { id: string; data: Record<string, unknown> }[] = [];
const deleteIds = Array.from(selectedDeleteIds);
// Build update objects combining text and target changes
// Build update objects combining text, target, and intensity changes
suggestions.forEach(suggestion => {
if (selectedDeleteIds.has(suggestion.questionId)) return;
const textSelected = selectedTextIds.has(suggestion.questionId) && suggestion.textSuggestion;
const targetSelected = selectedTargetIds.has(suggestion.questionId) && suggestion.targetSuggestion;
const propsSelected = selectedPropsIds.has(suggestion.questionId) && suggestion.propsSuggestion;
const intensitySelected = selectedIntensityIds.has(suggestion.questionId) &&
suggestion.textSuggestion?.suggested_intensity != null;
if (textSelected || targetSelected) {
if (textSelected || targetSelected || propsSelected || intensitySelected) {
const updateData: Record<string, unknown> = {};
if (textSelected && suggestion.textSuggestion) {
@@ -156,23 +229,49 @@ export function ReviewQuestionsDialog({ open, onOpenChange, questions, isExplici
: null;
}
if (propsSelected && suggestion.propsSuggestion) {
updateData.required_props = suggestion.propsSuggestion.suggested_required_props &&
suggestion.propsSuggestion.suggested_required_props.length > 0
? suggestion.propsSuggestion.suggested_required_props
: null;
}
if (intensitySelected && suggestion.textSuggestion?.suggested_intensity != null) {
updateData.intensity = suggestion.textSuggestion.suggested_intensity;
}
if (Object.keys(updateData).length > 0) {
updates.push({ id: suggestion.questionId, data: updateData });
}
}
});
if (updates.length === 0) {
if (updates.length === 0 && deleteIds.length === 0) {
onOpenChange(false);
return;
}
// Apply updates in parallel with audit logging
await Promise.all(updates.map(update =>
// Apply updates/deletions in parallel with audit logging
const operations: Promise<unknown>[] = updates.map(update =>
auditedSupabase.update('questions', update.id, update.data)
));
);
toast.success(`Updated ${updates.length} questions`);
if (deleteIds.length > 0) {
operations.push(
auditedSupabase.deleteMany('questions', deleteIds).then(result => {
if (result.error) throw result.error;
})
);
}
await Promise.all(operations);
const updateMessage = updates.length > 0 ? `Updated ${updates.length} questions` : null;
const deleteMessage = deleteIds.length > 0 ? `Deleted ${deleteIds.length} questions` : null;
const message = [updateMessage, deleteMessage].filter(Boolean).join('. ');
if (message) {
toast.success(message);
}
onUpdated();
handleClose();
} catch (error) {
@@ -190,6 +289,9 @@ export function ReviewQuestionsDialog({ open, onOpenChange, questions, isExplici
setSuggestions([]);
setSelectedTextIds(new Set());
setSelectedTargetIds(new Set());
setSelectedPropsIds(new Set());
setSelectedIntensityIds(new Set());
setSelectedDeleteIds(new Set());
}, 300);
};
@@ -207,6 +309,27 @@ export function ReviewQuestionsDialog({ open, onOpenChange, questions, isExplici
setSelectedTargetIds(next);
};
const togglePropsSelection = (id: string) => {
const next = new Set(selectedPropsIds);
if (next.has(id)) next.delete(id);
else next.add(id);
setSelectedPropsIds(next);
};
const toggleIntensitySelection = (id: string) => {
const next = new Set(selectedIntensityIds);
if (next.has(id)) next.delete(id);
else next.add(id);
setSelectedIntensityIds(next);
};
const toggleDeleteSelection = (id: string) => {
const next = new Set(selectedDeleteIds);
if (next.has(id)) next.delete(id);
else next.add(id);
setSelectedDeleteIds(next);
};
const formatTargets = (targets: string[] | null) => {
if (!targets || targets.length === 0) return 'All';
return targets.map(t => t.replace('male+male', 'M+M').replace('female+male', 'M+F').replace('female+female', 'F+F')).join(', ');
@@ -217,35 +340,89 @@ export function ReviewQuestionsDialog({ open, onOpenChange, questions, isExplici
return targets.map(t => t.charAt(0).toUpperCase() + t.slice(1)).join(', ');
};
const formatProps = (props: string[] | null | undefined) => {
if (!props || props.length === 0) return 'None';
return props.join(', ');
};
const formatDeleteCategory = (category?: DeletionAnalysis['category']) => {
switch (category) {
case 'duplicate':
return 'Duplicate';
case 'redundant':
return 'Redundant';
case 'off-tone':
return 'Off tone';
case 'unsafe':
return 'Unsafe';
case 'too-vague':
return 'Too vague';
case 'broken':
return 'Broken';
case 'off-topic':
return 'Off topic';
default:
return 'Delete';
}
};
const getQuestionTextById = (id?: string | null) => {
if (!id) return null;
const match = questions.find(q => q.id === id);
return match ? match.text : null;
};
// Count suggestions by type
const textSuggestionCount = suggestions.filter(s => s.textSuggestion).length;
const targetSuggestionCount = suggestions.filter(s => s.targetSuggestion).length;
const totalSelectedCount = selectedTextIds.size + selectedTargetIds.size;
const propsSuggestionCount = suggestions.filter(s => s.propsSuggestion).length;
const intensitySuggestionCount = suggestions.filter(s => s.textSuggestion?.suggested_intensity != null).length;
const deleteSuggestionCount = suggestions.filter(s => s.deleteSuggestion).length;
const effectiveTextCount = Array.from(selectedTextIds).filter(id => !selectedDeleteIds.has(id)).length;
const effectiveTargetCount = Array.from(selectedTargetIds).filter(id => !selectedDeleteIds.has(id)).length;
const effectivePropsCount = Array.from(selectedPropsIds).filter(id => !selectedDeleteIds.has(id)).length;
const effectiveIntensityCount = Array.from(selectedIntensityIds).filter(id => !selectedDeleteIds.has(id)).length;
const totalSelectedCount = effectiveTextCount + effectiveTargetCount + effectivePropsCount + effectiveIntensityCount + selectedDeleteIds.size;
const hasSuggestions = textSuggestionCount > 0 || targetSuggestionCount > 0 || propsSuggestionCount > 0 || intensitySuggestionCount > 0 || deleteSuggestionCount > 0;
// Select all/none helpers
const selectAllText = () => setSelectedTextIds(new Set(suggestions.filter(s => s.textSuggestion).map(s => s.questionId)));
const selectNoneText = () => setSelectedTextIds(new Set());
const selectAllTargets = () => setSelectedTargetIds(new Set(suggestions.filter(s => s.targetSuggestion).map(s => s.questionId)));
const selectNoneTargets = () => setSelectedTargetIds(new Set());
const selectAllProps = () => setSelectedPropsIds(new Set(suggestions.filter(s => s.propsSuggestion).map(s => s.questionId)));
const selectNoneProps = () => setSelectedPropsIds(new Set());
const selectAllIntensity = () => setSelectedIntensityIds(new Set(
suggestions.filter(s => s.textSuggestion?.suggested_intensity != null).map(s => s.questionId)
));
const selectNoneIntensity = () => setSelectedIntensityIds(new Set());
const selectAllDeletes = () => setSelectedDeleteIds(new Set(suggestions.filter(s => s.deleteSuggestion).map(s => s.questionId)));
const selectNoneDeletes = () => setSelectedDeleteIds(new Set());
const selectAll = () => {
selectAllText();
selectAllTargets();
selectAllProps();
selectAllIntensity();
selectAllDeletes();
};
const selectNone = () => {
selectNoneText();
selectNoneTargets();
selectNoneProps();
selectNoneIntensity();
selectNoneDeletes();
};
return (
<Dialog open={open} onOpenChange={handleClose}>
<DialogContent className="max-w-6xl max-h-[90vh] flex flex-col">
<DialogContent className="max-w-[95vw] w-full h-[95vh] flex flex-col">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Sparkles className="h-5 w-5 text-amber-500" />
Review Questions
</DialogTitle>
<DialogDescription>
AI-powered analysis to improve text phrasing and fix audience targeting.
AI-powered analysis to improve phrasing, identify props, fix targeting, and suggest deletions.
</DialogDescription>
</DialogHeader>
@@ -258,7 +435,7 @@ export function ReviewQuestionsDialog({ open, onOpenChange, questions, isExplici
<div className="space-y-2">
<h3 className="text-lg font-medium">Ready to analyze {questions.length} questions</h3>
<p className="text-muted-foreground max-w-lg mx-auto">
The AI will analyze all questions and suggest improvements for:
The AI will analyze all questions and suggest improvements or removals for:
</p>
</div>
<div className="flex gap-4">
@@ -270,6 +447,18 @@ export function ReviewQuestionsDialog({ open, onOpenChange, questions, isExplici
<ShieldCheck className="h-5 w-5 text-indigo-400" />
<span className="text-sm font-medium text-indigo-300">Audience Targeting</span>
</div>
<div className="flex items-center gap-2 bg-cyan-500/20 px-4 py-2 rounded-lg">
<Package className="h-5 w-5 text-cyan-400" />
<span className="text-sm font-medium text-cyan-300">Required Props</span>
</div>
<div className="flex items-center gap-2 bg-amber-500/20 px-4 py-2 rounded-lg">
<Gauge className="h-5 w-5 text-amber-400" />
<span className="text-sm font-medium text-amber-300">Intensity Level</span>
</div>
<div className="flex items-center gap-2 bg-rose-500/20 px-4 py-2 rounded-lg">
<Trash2 className="h-5 w-5 text-rose-400" />
<span className="text-sm font-medium text-rose-300">Deletion Suggestions</span>
</div>
</div>
<Button onClick={handleAnalyze} disabled={analyzing} size="lg" className="bg-amber-600 hover:bg-amber-700">
{analyzing ? (
@@ -284,7 +473,7 @@ export function ReviewQuestionsDialog({ open, onOpenChange, questions, isExplici
</div>
) : (
<div className="space-y-4">
{suggestions.length === 0 ? (
{!hasSuggestions ? (
<div className="text-center py-8 text-muted-foreground">
<Check className="h-8 w-8 mx-auto mb-2 text-green-500" />
All questions look great! No improvements needed.
@@ -302,6 +491,18 @@ export function ReviewQuestionsDialog({ open, onOpenChange, questions, isExplici
<ShieldCheck className="h-4 w-4 text-indigo-500" />
{targetSuggestionCount} target fixes
</span>
<span className="flex items-center gap-1">
<Package className="h-4 w-4 text-cyan-500" />
{propsSuggestionCount} props fixes
</span>
<span className="flex items-center gap-1">
<Gauge className="h-4 w-4 text-amber-500" />
{intensitySuggestionCount} intensity fixes
</span>
<span className="flex items-center gap-1">
<Trash2 className="h-4 w-4 text-rose-500" />
{deleteSuggestionCount} delete suggestions
</span>
</div>
<div className="flex items-center gap-2">
<Button variant="ghost" size="sm" onClick={selectAll}>
@@ -313,12 +514,12 @@ export function ReviewQuestionsDialog({ open, onOpenChange, questions, isExplici
</div>
</div>
<div className="border rounded-md">
<div className="border rounded-md overflow-hidden">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-[300px]">Question</TableHead>
<TableHead>
<TableHead className="w-[18%] min-w-[180px]">Question</TableHead>
<TableHead className="w-[26%]">
<div className="flex items-center gap-2">
<Wand2 className="h-4 w-4 text-purple-500" />
Text Fix
@@ -332,7 +533,7 @@ export function ReviewQuestionsDialog({ open, onOpenChange, questions, isExplici
</Button>
</div>
</TableHead>
<TableHead>
<TableHead className="w-[18%]">
<div className="flex items-center gap-2">
<ShieldCheck className="h-4 w-4 text-indigo-500" />
Target Fix
@@ -346,32 +547,78 @@ export function ReviewQuestionsDialog({ open, onOpenChange, questions, isExplici
</Button>
</div>
</TableHead>
<TableHead className="w-[18%]">
<div className="flex items-center gap-2">
<Package className="h-4 w-4 text-cyan-500" />
Props
<Button
variant="ghost"
size="sm"
className="h-6 px-2 text-xs"
onClick={() => selectedPropsIds.size === propsSuggestionCount ? selectNoneProps() : selectAllProps()}
>
{selectedPropsIds.size === propsSuggestionCount ? 'None' : 'All'}
</Button>
</div>
</TableHead>
<TableHead className="w-[10%]">
<div className="flex items-center gap-2">
<Gauge className="h-4 w-4 text-amber-500" />
Intensity
{intensitySuggestionCount > 0 && (
<Button
variant="ghost"
size="sm"
className="h-6 px-2 text-xs"
onClick={() => selectedIntensityIds.size === intensitySuggestionCount ? selectNoneIntensity() : selectAllIntensity()}
>
{selectedIntensityIds.size === intensitySuggestionCount ? 'None' : 'All'}
</Button>
)}
</div>
</TableHead>
<TableHead className="w-[10%]">
<div className="flex items-center gap-2">
<Trash2 className="h-4 w-4 text-rose-500" />
Delete
{deleteSuggestionCount > 0 && (
<Button
variant="ghost"
size="sm"
className="h-6 px-2 text-xs"
onClick={() => selectedDeleteIds.size === deleteSuggestionCount ? selectNoneDeletes() : selectAllDeletes()}
>
{selectedDeleteIds.size === deleteSuggestionCount ? 'None' : 'All'}
</Button>
)}
</div>
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{suggestions.map((suggestion) => (
<TableRow key={suggestion.questionId}>
{/* Question Column */}
<TableCell className="align-top">
<div className="text-sm font-medium line-clamp-2">{suggestion.question.text}</div>
<TableCell className="align-top py-3">
<div className="text-sm font-medium">{suggestion.question.text}</div>
{suggestion.question.partner_text && (
<div className="text-xs text-muted-foreground mt-1 italic line-clamp-1">
<div className="text-xs text-muted-foreground mt-1.5 italic">
Partner: {suggestion.question.partner_text}
</div>
)}
</TableCell>
{/* Text Fix Column */}
<TableCell className="align-top">
<TableCell className="align-top py-3">
{suggestion.textSuggestion ? (
<div
className={`p-2 rounded-lg border cursor-pointer transition-colors ${selectedTextIds.has(suggestion.questionId)
className={`p-3 rounded-lg border cursor-pointer transition-colors ${selectedTextIds.has(suggestion.questionId)
? 'bg-purple-500/20 border-purple-500/50'
: 'bg-muted/30 border-transparent hover:bg-muted/50'
}`}
onClick={() => toggleTextSelection(suggestion.questionId)}
>
<div className="flex items-start gap-2">
<div className="flex items-start gap-3">
<Checkbox
checked={selectedTextIds.has(suggestion.questionId)}
onCheckedChange={() => toggleTextSelection(suggestion.questionId)}
@@ -382,11 +629,11 @@ export function ReviewQuestionsDialog({ open, onOpenChange, questions, isExplici
{suggestion.textSuggestion.suggested_text}
</div>
{suggestion.textSuggestion.suggested_partner_text && (
<div className="text-xs text-purple-400 mt-1 italic">
<div className="text-xs text-purple-400 mt-1.5 italic">
Partner: {suggestion.textSuggestion.suggested_partner_text}
</div>
)}
<div className="text-xs text-muted-foreground mt-1">
<div className="text-xs text-muted-foreground mt-2">
{suggestion.textSuggestion.reason}
</div>
</div>
@@ -401,16 +648,16 @@ export function ReviewQuestionsDialog({ open, onOpenChange, questions, isExplici
</TableCell>
{/* Target Fix Column */}
<TableCell className="align-top">
<TableCell className="align-top py-3">
{suggestion.targetSuggestion ? (
<div
className={`p-2 rounded-lg border cursor-pointer transition-colors ${selectedTargetIds.has(suggestion.questionId)
className={`p-3 rounded-lg border cursor-pointer transition-colors ${selectedTargetIds.has(suggestion.questionId)
? 'bg-indigo-500/20 border-indigo-500/50'
: 'bg-muted/30 border-transparent hover:bg-muted/50'
}`}
onClick={() => toggleTargetSelection(suggestion.questionId)}
>
<div className="flex items-start gap-2">
<div className="flex items-start gap-3">
<Checkbox
checked={selectedTargetIds.has(suggestion.questionId)}
onCheckedChange={() => toggleTargetSelection(suggestion.questionId)}
@@ -418,26 +665,28 @@ export function ReviewQuestionsDialog({ open, onOpenChange, questions, isExplici
/>
<div className="flex-1 min-w-0 space-y-2">
{/* Couple targets */}
<div className="flex items-center gap-1 flex-wrap">
<Badge variant="outline" className="text-[10px] text-muted-foreground">
<div className="flex items-center gap-2 flex-wrap">
<span className="text-xs text-muted-foreground">Couples:</span>
<Badge variant="outline" className="text-xs text-muted-foreground">
{formatTargets(suggestion.question.allowed_couple_genders)}
</Badge>
<ArrowRight className="h-3 w-3 text-muted-foreground" />
<Badge className="text-[10px] bg-indigo-500/30 text-indigo-300 hover:bg-indigo-500/30">
<Badge className="text-xs bg-indigo-500/30 text-indigo-300 hover:bg-indigo-500/30">
{formatTargets(suggestion.targetSuggestion.suggested_targets)}
</Badge>
</div>
{/* Initiator */}
<div className="flex items-center gap-1 flex-wrap">
<Badge variant="outline" className="text-[10px] text-muted-foreground border-orange-500/50">
<div className="flex items-center gap-2 flex-wrap">
<span className="text-xs text-muted-foreground">Initiator:</span>
<Badge variant="outline" className="text-xs text-muted-foreground border-orange-500/50">
{formatInitiator(suggestion.question.target_user_genders)}
</Badge>
<ArrowRight className="h-3 w-3 text-muted-foreground" />
<Badge className="text-[10px] bg-orange-500/30 text-orange-300 hover:bg-orange-500/30">
<Badge className="text-xs bg-orange-500/30 text-orange-300 hover:bg-orange-500/30">
{formatInitiator(suggestion.targetSuggestion.suggested_initiator)}
</Badge>
</div>
<div className="text-xs text-muted-foreground">
<div className="text-xs text-muted-foreground mt-2">
{suggestion.targetSuggestion.reason}
</div>
</div>
@@ -450,6 +699,130 @@ export function ReviewQuestionsDialog({ open, onOpenChange, questions, isExplici
</div>
)}
</TableCell>
{/* Props Column */}
<TableCell className="align-top py-3">
{suggestion.propsSuggestion ? (
<div
className={`p-3 rounded-lg border cursor-pointer transition-colors ${selectedPropsIds.has(suggestion.questionId)
? 'bg-cyan-500/20 border-cyan-500/50'
: 'bg-muted/30 border-transparent hover:bg-muted/50'
}`}
onClick={() => togglePropsSelection(suggestion.questionId)}
>
<div className="flex items-start gap-3">
<Checkbox
checked={selectedPropsIds.has(suggestion.questionId)}
onCheckedChange={() => togglePropsSelection(suggestion.questionId)}
className="mt-0.5"
/>
<div className="flex-1 min-w-0 space-y-2">
<div className="flex items-center gap-2 flex-wrap">
<span className="text-xs text-muted-foreground">Current:</span>
<Badge variant="outline" className="text-xs text-muted-foreground">
{formatProps(suggestion.question.required_props)}
</Badge>
<ArrowRight className="h-3 w-3 text-muted-foreground" />
<Badge className="text-xs bg-cyan-500/30 text-cyan-300 hover:bg-cyan-500/30">
{formatProps(suggestion.propsSuggestion.suggested_required_props)}
</Badge>
</div>
<div className="text-xs text-muted-foreground mt-2">
{suggestion.propsSuggestion.reason}
</div>
</div>
</div>
</div>
) : (
<div className="text-xs text-muted-foreground p-2">
<Check className="h-4 w-4 text-green-500 inline mr-1" />
No changes needed
</div>
)}
</TableCell>
{/* Intensity Column */}
<TableCell className="align-top py-3">
<div className="flex items-center gap-2 mb-1">
<Badge variant="outline" className="text-xs">
Current: {suggestion.question.intensity}
</Badge>
</div>
{suggestion.textSuggestion?.suggested_intensity != null ? (
<div
className={`p-2 rounded-lg border cursor-pointer transition-colors ${selectedIntensityIds.has(suggestion.questionId)
? 'bg-amber-500/20 border-amber-500/50'
: 'bg-muted/30 border-transparent hover:bg-muted/50'
}`}
onClick={() => toggleIntensitySelection(suggestion.questionId)}
>
<div className="flex items-start gap-2">
<Checkbox
checked={selectedIntensityIds.has(suggestion.questionId)}
onCheckedChange={() => toggleIntensitySelection(suggestion.questionId)}
className="mt-0.5"
/>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<ArrowRight className="h-3 w-3 text-muted-foreground" />
<Badge className="text-xs bg-amber-500/30 text-amber-300 hover:bg-amber-500/30">
{suggestion.textSuggestion.suggested_intensity}
</Badge>
</div>
{suggestion.textSuggestion.intensity_reason && (
<div className="text-xs text-muted-foreground mt-1">
{suggestion.textSuggestion.intensity_reason}
</div>
)}
</div>
</div>
</div>
) : (
<div className="text-xs text-muted-foreground">
<Check className="h-4 w-4 text-green-500 inline mr-1" />
Correct
</div>
)}
</TableCell>
{/* Delete Column */}
<TableCell className="align-top py-3">
{suggestion.deleteSuggestion ? (
<div
className={`p-3 rounded-lg border cursor-pointer transition-colors ${selectedDeleteIds.has(suggestion.questionId)
? 'bg-rose-500/20 border-rose-500/50'
: 'bg-muted/30 border-transparent hover:bg-muted/50'
}`}
onClick={() => toggleDeleteSelection(suggestion.questionId)}
>
<div className="flex items-start gap-3">
<Checkbox
checked={selectedDeleteIds.has(suggestion.questionId)}
onCheckedChange={() => toggleDeleteSelection(suggestion.questionId)}
className="mt-0.5"
/>
<div className="flex-1 min-w-0">
<div className="text-sm text-rose-300 font-medium">
{formatDeleteCategory(suggestion.deleteSuggestion.category)} recommended
</div>
{suggestion.deleteSuggestion.duplicate_of_id && (
<div className="text-xs text-rose-400 mt-1">
Duplicate of: {getQuestionTextById(suggestion.deleteSuggestion.duplicate_of_id) || 'Another question'}
</div>
)}
<div className="text-xs text-muted-foreground mt-2">
{suggestion.deleteSuggestion.reason}
</div>
</div>
</div>
</div>
) : (
<div className="text-xs text-muted-foreground p-2">
<Check className="h-4 w-4 text-green-500 inline mr-1" />
Keep
</div>
)}
</TableCell>
</TableRow>
))}
</TableBody>
@@ -478,7 +851,7 @@ export function ReviewQuestionsDialog({ open, onOpenChange, questions, isExplici
Applying...
</>
) : (
`Apply ${totalSelectedCount} Changes`
`Apply ${totalSelectedCount} Actions`
)}
</Button>
</>
@@ -109,6 +109,8 @@ function useBreadcrumbs(): { breadcrumbs: BreadcrumbItem[]; loading: boolean } {
});
} else if (segment === 'questions' && params.packId) {
breadcrumbs.push({ label: 'Questions', href: currentPath });
} else if (segment === 'tags') {
breadcrumbs.push({ label: 'Tags', href: '/tags' });
} else if (segment === 'users') {
breadcrumbs.push({ label: 'Users', href: '/users' });
// If we have a userId, add the user's name as a breadcrumb
@@ -18,6 +18,8 @@ import {
Home,
Activity,
AlertTriangle,
PieChart,
Tags,
Zap,
} from 'lucide-react';
import { Button } from '@/components/ui/button';
@@ -54,6 +56,18 @@ const contentNav: NavItem[] = [
icon: <Zap className="h-5 w-5" />,
requiredPermission: PERMISSION_KEYS.MANAGE_PACKS,
},
{
label: 'Question Analytics',
href: '/question-analytics',
icon: <PieChart className="h-5 w-5" />,
requiredPermission: PERMISSION_KEYS.MANAGE_QUESTIONS,
},
{
label: 'Tags',
href: '/tags',
icon: <Tags className="h-5 w-5" />,
requiredPermission: PERMISSION_KEYS.MANAGE_PACKS,
},
];
const usersNav: NavItem[] = [
+3 -2
View File
@@ -3,15 +3,16 @@ import { cn } from "@/lib/utils";
import { Check } from "lucide-react";
export interface CheckboxProps
extends Omit<React.InputHTMLAttributes<HTMLInputElement>, 'type' | 'onChange'> {
extends Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, 'onChange'> {
checked?: boolean;
onCheckedChange?: (checked: boolean) => void;
}
const Checkbox = React.forwardRef<HTMLInputElement, CheckboxProps>(
const Checkbox = React.forwardRef<HTMLButtonElement, CheckboxProps>(
({ className, checked, onCheckedChange, ...props }, ref) => {
return (
<button
ref={ref}
type="button"
role="checkbox"
aria-checked={checked}
+7
View File
@@ -3,6 +3,7 @@ import { supabase } from '@/config';
export interface CouncilGenerator {
model: string;
temperature?: number;
}
export type CouncilSelectionMode = 'whole_set' | 'cherry_pick';
@@ -11,17 +12,23 @@ export interface AiConfig {
id: string;
openrouter_api_key: string | null;
default_model: string | null;
default_temperature: number | null;
model_generate: string | null;
temperature_generate: number | null;
model_fix: string | null;
temperature_fix: number | null;
model_polish: string | null;
temperature_polish: number | null;
council_enabled: boolean;
council_generator_model: string | null; // Legacy - kept for backwards compatibility
council_generators: CouncilGenerator[] | null; // New array of generators
council_reviewer_model: string | null;
council_reviewer_temperature: number | null;
council_selection_mode: CouncilSelectionMode | null; // 'whole_set' or 'cherry_pick'
cherry_pick_ensure_intensity_distribution: boolean | null; // Balance intensity levels in cherry-pick mode
classifier_enabled: boolean | null;
classifier_model: string | null;
classifier_temperature: number | null;
classifier_prompt: string | null;
heuristics_enabled: boolean | null;
heuristic_min_text_length: number | null;
+10 -6
View File
@@ -3,7 +3,8 @@ import { supabase } from '@/config';
import { auditedSupabase } from '@/hooks/useAuditedSupabase';
import { toast } from 'sonner';
type SupabaseFilterFn<T> = (query: ReturnType<typeof supabase.from>) => ReturnType<typeof supabase.from>;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
type SupabaseFilterFn = (query: any) => any;
interface UseCrudOperationsOptions<T> {
/**
@@ -89,15 +90,16 @@ export function useCrudOperations<T extends { id: string }>({
* @param filter Optional filter function to modify the query
*/
const fetchAll = useCallback(async (
filter?: SupabaseFilterFn<T>
filter?: SupabaseFilterFn
): Promise<T[]> => {
setLoading(true);
setError(null);
try {
let query = supabase.from(table).select(select);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let query: any = supabase.from(table).select(select);
if (filter) {
query = filter(query) as typeof query;
query = filter(query);
}
if (orderBy) {
@@ -155,7 +157,8 @@ export function useCrudOperations<T extends { id: string }>({
): Promise<T | null> => {
const { refetch = true, silent = false } = options;
try {
const { data: created, error: createError } = await auditedSupabase.insert(table, data);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const { data: created, error: createError } = await auditedSupabase.insert(table, data as any);
if (createError) throw createError;
@@ -167,7 +170,8 @@ export function useCrudOperations<T extends { id: string }>({
await fetchAll();
}
return created as T;
// auditedSupabase.insert returns T[] | null, get first element
return created && created.length > 0 ? (created[0] as T) : null;
} catch (err) {
if (!silent) {
toast.error(defaultMessages.createError);
@@ -0,0 +1,102 @@
// =============================================================================
// Deletion Analysis Functions
// Suggest removals for low-quality or duplicate questions
// =============================================================================
import { getOpenAI, getModel, getTemperature } from '../client';
import type { DeletionAnalysis } from '../types';
/**
* Analyze questions and suggest deletions (duplicates, unsafe, etc.)
*/
export async function analyzeQuestionDeletions(
questions: { id: string; text: string; partner_text?: string | null; intensity?: number }[],
isExplicit: boolean = false
): Promise<DeletionAnalysis[]> {
const openai = getOpenAI();
const simplifiedQuestions = questions.map(q => ({
id: q.id,
text: q.text,
partner_text: q.partner_text,
intensity: q.intensity,
}));
const toneInstruction = isExplicit
? 'CONTENT: EXPLICIT pack. Adult content is allowed but must be consensual, safe, and clear. Avoid coercion, minors, illegal acts, or unsafe suggestions.'
: 'CONTENT: NON-EXPLICIT pack. Do NOT include explicit sexual acts, crude language, or NSFW content. If the core action is sexual, it should be removed, not rewritten.';
const prompt = `<task>
Identify questions that should be DELETED instead of edited.
Only include questions that are better removed than fixed.
</task>
<content_type>
${toneInstruction}
</content_type>
<deletion_categories>
- duplicate: same core action AND same initiator as another question (near-duplicate). Note: swapped perspectives (e.g., "give" vs "receive") are NOT duplicates.
- redundant: minor variation with no added value
- off-tone: violates explicit vs non-explicit rules
- unsafe: coercion, minors, illegal acts, or consent violations
- too-vague: not actionable or not a clear proposal
- broken: grammar/structure too broken to repair without inventing content
- off-topic: not relevant to a couples intimacy app
</deletion_categories>
<rules>
1. Prefer edits over deletions when possible.
2. Duplicates: delete the weaker/less specific version and keep the clearer one.
3. If duplicate, set duplicate_of_id to the kept question's id.
4. Do NOT delete for minor wording issues or intensity mismatches.
5. IMPORTANT: Asymmetric versions of the same activity are NOT duplicates. If one question has text="Do X to your partner" and another has text="Let your partner do X to you", these are DIFFERENT questions because they have different initiators. Only flag as duplicate if both questions have the SAME initiator doing the SAME action.
</rules>
<questions_to_analyze>
${JSON.stringify(simplifiedQuestions)}
</questions_to_analyze>
<output_format>
{
"deletions": [
{
"id": string,
"category": "duplicate"|"redundant"|"off-tone"|"unsafe"|"too-vague"|"broken"|"off-topic",
"reason": string,
"duplicate_of_id": string|null
}
]
}
Only return questions that should be deleted.
</output_format>`;
const systemMessage = isExplicit
? 'You are a strict content quality reviewer for an adult couples app. Remove only what should be deleted. Always respond with valid JSON only.'
: 'You are a strict content quality reviewer for a couples app. Remove only what should be deleted. Always respond with valid JSON only.';
const response = await openai.chat.completions.create({
model: getModel('fix'),
messages: [
{
role: 'system',
content: systemMessage,
},
{ role: 'user', content: prompt },
],
response_format: { type: 'json_object' },
temperature: getTemperature('fix', 0.2),
});
const content = response.choices[0].message.content;
if (!content) throw new Error('No content generated');
let jsonContent = content.trim();
if (jsonContent.startsWith('```')) {
jsonContent = jsonContent.replace(/^```(?:json)?\s*\n?/, '').replace(/\n?```\s*$/, '');
}
const parsed = JSON.parse(jsonContent);
return parsed.deletions || [];
}
+73 -2
View File
@@ -1,8 +1,53 @@
import { getOpenAI, getModel } from '../client';
import { getOpenAI, getModel, getTemperature } from '../client';
export interface ImageSafetyAnalysis {
category: 'SAFE' | 'ADULT_CONTENT' | 'VIOLENCE' | 'MINORS' | 'ILLEGAL' | 'UNCERTAIN';
severity: 'none' | 'low' | 'medium' | 'high' | 'critical';
description: string;
concerns: string[];
isAppropriate: boolean;
}
const SAFETY_ANALYSIS_PROMPT = `<task>
Analyze this image for content safety in the context of an adult couples intimacy app.
</task>
<context>
This app is for ADULT couples (25-45) to explore intimacy. Adult content between consenting adults is expected and acceptable. The concern is inappropriate content like violence, minors, or illegal activity.
</context>
<categories>
- SAFE: No concerns, appropriate for the app (including tasteful adult content between adults)
- ADULT_CONTENT: Explicit content - acceptable for this app but flag for review
- VIOLENCE: Gore, weapons, abuse, non-consensual acts
- MINORS: Anyone appearing under 18 years old - CRITICAL, never acceptable
- ILLEGAL: Drug use, criminal activity, non-consensual content
- UNCERTAIN: Cannot determine, needs human review
</categories>
<severity_levels>
- none: No safety concerns at all
- low: Minor flag, likely fine but worth noting
- medium: Should be reviewed by admin
- high: Likely inappropriate, requires immediate review
- critical: Definitely inappropriate, should be blocked (MINORS, VIOLENCE, ILLEGAL)
</severity_levels>
<output_format>
Return a JSON object:
{
"category": "SAFE"|"ADULT_CONTENT"|"VIOLENCE"|"MINORS"|"ILLEGAL"|"UNCERTAIN",
"severity": "none"|"low"|"medium"|"high"|"critical",
"description": "Brief factual description of what's shown (max 50 words)",
"concerns": ["List of specific concerns if any, empty array if none"],
"isAppropriate": true|false // false for VIOLENCE, MINORS, ILLEGAL or high/critical severity
}
</output_format>`;
export async function describeImage(imageBase64OrUrl: string): Promise<string> {
const openai = getOpenAI();
const model = getModel('describe_image');
const temperature = getTemperature('describe_image', 0.0); // Deterministic for safety
// If it's a data URL or http URL, use it as is.
// If it's raw base64, prepend the data URI scheme.
@@ -18,7 +63,7 @@ export async function describeImage(imageBase64OrUrl: string): Promise<string> {
{
role: 'user',
content: [
{ type: 'text', text: 'Describe this image in detail. Focus on any potentially inappropriate content or safety concerns.' },
{ type: 'text', text: SAFETY_ANALYSIS_PROMPT },
{
type: 'image_url',
image_url: {
@@ -29,6 +74,7 @@ export async function describeImage(imageBase64OrUrl: string): Promise<string> {
},
],
max_tokens: 300,
temperature: temperature,
});
return response.choices[0].message.content || 'No description available.';
@@ -37,3 +83,28 @@ export async function describeImage(imageBase64OrUrl: string): Promise<string> {
throw error;
}
}
/**
* Analyze image safety and return structured result
*/
export async function analyzeImageSafety(imageBase64OrUrl: string): Promise<ImageSafetyAnalysis> {
const rawResult = await describeImage(imageBase64OrUrl);
try {
// Try to parse as JSON
let jsonContent = rawResult.trim();
if (jsonContent.startsWith('```')) {
jsonContent = jsonContent.replace(/^```(?:json)?\s*\n?/, '').replace(/\n?```\s*$/, '');
}
return JSON.parse(jsonContent) as ImageSafetyAnalysis;
} catch {
// If parsing fails, return a default "uncertain" result
return {
category: 'UNCERTAIN',
severity: 'medium',
description: rawResult.slice(0, 200),
concerns: ['Could not parse safety analysis result'],
isAppropriate: false,
};
}
}
+89
View File
@@ -0,0 +1,89 @@
// =============================================================================
// Props Analysis Functions
// Identify required props/accessories for questions
// =============================================================================
import { getOpenAI, getModel, getTemperature } from '../client';
import type { PropsAnalysis } from '../types';
/**
* Analyze questions for required props/accessories
*/
export async function analyzeQuestionProps(
questions: {
id: string;
text: string;
partner_text?: string | null;
required_props?: string[] | null;
}[],
existingProps: string[] = []
): Promise<PropsAnalysis[]> {
const openai = getOpenAI();
const normalizedExistingProps = existingProps
.map(prop => prop.trim())
.filter(Boolean);
const simplifiedQuestions = questions.map(q => ({
id: q.id,
text: q.text,
partner_text: q.partner_text,
current_required_props: q.required_props ?? null,
}));
const prompt = `<task>
Identify any props/accessories required to perform each question.
Only include questions where required props should change (missing, incorrect, or overly generic).
</task>
<existing_props>
Prefer these exact prop names when possible (reuse existing names instead of inventing new ones):
${JSON.stringify(normalizedExistingProps)}
</existing_props>
<rules>
1. Props are physical items/accessories, not body parts or acts.
2. Use lowercase, short, singular names (e.g., "blindfold", "remote vibrator").
3. If a prop is essentially the same as an existing one, reuse the existing name.
4. Avoid making up new variants when a close existing name is available.
5. Example: if "remote vibrator" exists, map "wifi" or "app-controlled" variants to "remote vibrator".
6. Use specific items when clearly stated; otherwise allow "toy" or "props".
7. If no props are required, set suggested_required_props to null.
8. Only return entries where the suggested props differ from current_required_props.
</rules>
<questions_to_analyze>
${JSON.stringify(simplifiedQuestions)}
</questions_to_analyze>
<output_format>
{
"analysis": [
{
"id": string,
"suggested_required_props": string[]|null,
"reason": string
}
]
}
</output_format>`;
const response = await openai.chat.completions.create({
model: getModel('fix'),
messages: [
{
role: 'system',
content: 'You are a content analyst for a couples app. Identify required props/accessories and return valid JSON.'
},
{ role: 'user', content: prompt }
],
response_format: { type: 'json_object' },
temperature: getTemperature('fix', 0.3),
});
const content = response.choices[0].message.content;
if (!content) throw new Error('No content generated');
const parsed = JSON.parse(content);
return parsed.analysis || [];
}
+50 -42
View File
@@ -3,7 +3,7 @@
// Analyze questions for gender targeting and couple compatibility
// =============================================================================
import { getOpenAI, getModel } from '../client';
import { getOpenAI, getModel, getTemperature } from '../client';
import type { TargetAnalysis } from '../types';
/**
@@ -28,59 +28,67 @@ export async function analyzeQuestionTargets(
current_initiator: q.target_user_genders
}));
const prompt = `Analyze the following questions for a couples app and suggest:
1. "allowed_couple_genders" (which couple types can see this question)
2. "target_user_genders" (who should see this question FIRST as initiator - only relevant for two-part questions with partner_text)
const prompt = `<task>
Analyze questions for couple targeting and initiator targeting.
Only include questions where you have a recommendation to change current settings.
</task>
COUPLE TARGET OPTIONS: 'male+male', 'female+male', 'female+female'
INITIATOR OPTIONS: 'male', 'female', 'non-binary'
<targeting_options>
Couple targets (allowed_couple_genders): 'male+male', 'female+male', 'female+female'
Initiator targets (target_user_genders): 'male', 'female', 'non-binary'
</targeting_options>
RULES FOR COUPLE TARGETS (allowed_couple_genders):
- Default to ALL (null) if the action is gender-neutral/universal (e.g. kissing, massage, generic sex, anal).
- Limit ONLY if there is a clear, explicit anatomical requirement (e.g. genitalia-specific terms).
- SEX TOYS ARE GENDER-NEUTRAL: Vibrators, dildos, plugs, etc. can be used by ANYONE. Do NOT restrict based on toys.
- "Control your partner's vibrator" -> ALL couples, ANY initiator (vibrators work on any body)
- "Use a butt plug" -> ALL couples (everyone has a butt)
<couple_targeting_rules>
DEFAULT: null (ALL couples) unless explicit anatomical requirement
Examples of ACTUAL restrictions:
- "Blowjob" -> Needs penis -> Exclude F+F -> ['male+male', 'female+male']
- "Lick pussy" -> Needs vagina -> Exclude M+M -> ['female+male', 'female+female']
- "Vibrator", "dildo", "toy" -> NO restriction, works for everyone
Restrict ONLY when:
- Activity requires penis -> ['male+male', 'female+male'] (exclude F+F)
- Activity requires vagina -> ['female+male', 'female+female'] (exclude M+M)
RULES FOR INITIATOR (target_user_genders):
- ONLY relevant for TWO-PART questions (those with partner_text set).
- Analyze the "text" field to determine who should see this card FIRST as the initiator.
- ONLY set initiator if there is an EXPLICIT anatomical requirement in the text.
- SEX TOYS DO NOT IMPLY GENDER: "vibrator", "dildo", "toy", "plug" -> initiator: null (anyone can use/control toys)
- If text is gender-neutral, leave as null. When in doubt, use null.
Key considerations for initiator:
1. Does the "text" describe receiving something from a penis? -> Female initiator (in M+F context)
2. Does the "text" describe an action typically done BY someone with a penis (e.g., "my cum", "cum on you")? -> Male initiator
3. Does the "text" describe receiving penetration? -> Consider who is typically penetrated
4. Is the action truly symmetric with no gendered context? -> null
SEX TOYS ARE GENDER-NEUTRAL:
- Vibrators, dildos, plugs work on ANYONE -> null (no restriction)
- "Control your partner's vibrator" -> null
- "Use a butt plug" -> null (everyone has a butt)
Examples:
- text: "Swallow your partner's cum" -> Receiving cum requires partner has penis, so in M+F this is female-oriented -> initiator: ['female']
- text: "Lick my cum off your body" -> "my cum" implies initiator produces cum (has penis) -> initiator: ['male']
- text: "Deep throat your partner" -> Requires partner has penis, so receiver-focused -> initiator: ['female'] for M+F
- text: "Give your partner a massage" -> Truly gender-neutral -> initiator: null
- text: "Let her ride you" / partner_text: "Ride your partner" -> "her" implies female partner -> initiator: ['male']
- text: "Finger your partner" / partner_text: "Be fingered" -> Receiver needs vagina -> suggested_targets: exclude M+M, initiator: null (either can finger)
- "Blowjob" -> needs penis -> ['male+male', 'female+male']
- "Lick pussy" -> needs vagina -> ['female+male', 'female+female']
- "Use a toy" -> gender-neutral -> null
</couple_targeting_rules>
Return a JSON object with an "analysis" array containing objects for questions where you have a recommendation.
Include ALL fields even if null. Include a "reason" string explaining your suggestions.
<initiator_targeting_rules>
ONLY for asymmetric questions (those with partner_text).
DEFAULT: null (anyone can initiate)
Structure:
Set initiator ONLY when "text" field has explicit anatomical requirement:
- "Swallow your partner's cum" -> partner has penis -> initiator: ['female'] in M+F
- "Lick my cum off your body" -> "my cum" = initiator has penis -> ['male']
- "Deep throat your partner" -> partner has penis -> ['female'] in M+F
- "Give your partner a massage" -> gender-neutral -> null
- "Let her ride you" -> "her" implies female partner -> ['male']
- Sex toys -> null (anyone can use/control toys)
When in doubt, use null.
</initiator_targeting_rules>
<questions_to_analyze>
${JSON.stringify(simplifiedQuestions)}
</questions_to_analyze>
<output_format>
{
"analysis": [
{ "id": "...", "suggested_targets": ["male+male", "female+male"], "suggested_initiator": ["male"], "reason": "Text implies male initiator with female partner" }
{
"id": string,
"suggested_targets": string[]|null, // e.g., ["male+male", "female+male"] or null
"suggested_initiator": string[]|null, // e.g., ["male"] or null
"reason": string // Explanation of recommendation
}
]
}
Questions:
${JSON.stringify(simplifiedQuestions)}
`;
Only include questions where targeting should change.
</output_format>`;
const response = await openai.chat.completions.create({
model: getModel('fix'),
@@ -92,7 +100,7 @@ ${JSON.stringify(simplifiedQuestions)}
{ role: 'user', content: prompt }
],
response_format: { type: 'json_object' },
temperature: 0.5,
temperature: getTemperature('fix', 0.5),
});
const content = response.choices[0].message.content;
+68 -42
View File
@@ -3,14 +3,14 @@
// Analyze and suggest improvements for question text
// =============================================================================
import { getOpenAI, getModel } from '../client';
import { getOpenAI, getModel, getTemperature } from '../client';
import type { TextAnalysis } from '../types';
/**
* Analyze question text and suggest improvements
*/
export async function analyzeQuestionText(
questions: { id: string; text: string; partner_text?: string | null }[],
questions: { id: string; text: string; partner_text?: string | null; intensity?: number }[],
isExplicit: boolean = false
): Promise<TextAnalysis[]> {
const openai = getOpenAI();
@@ -19,6 +19,7 @@ export async function analyzeQuestionText(
id: q.id,
text: q.text,
partner_text: q.partner_text,
intensity: q.intensity,
}));
const toneInstruction = isExplicit
@@ -38,56 +39,81 @@ export async function analyzeQuestionText(
- partner_text (The Receiver): Passive/Receiving proposal (e.g., "Receive a love letter from your partner", "Be surprised with a date by your partner").
- GOOD: "Give your partner a massage" / "Receive a massage from your partner"`;
const prompt = `Analyze the following questions for a couples app and suggest improved text that follows our style guidelines.
const prompt = `<task>
Analyze the following questions and suggest improved text that follows our style guidelines.
Only include questions that NEED improvement - skip well-phrased ones.
</task>
<content_type>
${toneInstruction}
</content_type>
<app_context>
The app uses a swipe-based interface (Like/Dislike/Maybe).
Cards should be "Proposals" relative to a specific action, NOT interview questions.
Cards should be "Proposals" for specific actions, NOT interview questions.
</app_context>
For each question, decide if it is a SYMMETRIC activity (shared) or ASYMMETRIC action (one-way).
1. SYMMETRIC Activities (Single Card - partner_text should be null):
- Use for shared experiences where both partners do the same thing together.
- Phrasing: Direct action proposal.
<question_types>
1. SYMMETRIC Activities (partner_text = null):
- Both partners do the same thing together
- ${symmetricExamples}
- BAD: "Have you ever thought about...", "Do you think we should...", "How about we..."
- BAD: "Have you ever thought about...", "Do you think we should..."
2. ASYMMETRIC Actions (Two-Part Card - needs both text and partner_text):
- Use for actions where one partner does something TO/FOR the other, or where roles differ.
- text = what the INITIATOR does (their action/perspective)
- partner_text = what the RECEIVER does/experiences (their action/perspective)
2. ASYMMETRIC Actions (needs both text AND partner_text):
- One partner does something TO/FOR the other
- text = what the INITIATOR does
- partner_text = what the RECEIVER experiences
- ${asymmetricExamples}
- IMPORTANT: partner_text should describe the RECEIVER's experience clearly:
- text: "Send your partner to the bathroom and follow a minute later"
- partner_text: "Go to the bathroom and wait for your partner to join you" (NOT "Wait for your partner to follow you")
- When initiator CAUSES a response (moan, cum, beg, etc.), partner_text should frame as ALLOWING/RECEIVING:
- text: "Make your partner moan in public" -> partner_text: "Let your partner make you moan in public"
- NOT: "Moan for your partner in public" (sounds like deliberate performance, not natural response)
- BAD: "Would you like to...?" (Ambiguous who "me" is)
CRITICAL RULES:
1. PRESERVE THE CORE ACTION - Do NOT change what the question is about. Only improve phrasing.
2. Keep text SHORT and DIRECT - no explanatory parentheticals. Aim for 5-12 words.
3. Remove wishy-washy language: "Would you want to...", "Have you ever...", "Do you think...", "...fantasy"
4. Make it direct and actionable.
5. For asymmetric cards, ensure text is initiator-focused and partner_text is receiver-focused.
6. Use "your partner" instead of "me", "you", "him", "her" to keep it gender-neutral.
7. If a question is ALREADY well-phrased, do NOT include it in the results.
8. NEVER add explanatory text in parentheses - keep the proposal clean and simple.
9. NEVER MIX ANATOMICALLY INCOMPATIBLE ACTIVITIES - Do NOT combine male-specific and female-specific acts as alternatives in the same question.
10. MAKE PARTNER_TEXT APPEALING - Don't just grammatically flip the text. Make the receiver feel excited.
11. FLAG CLICHES for improvement - "candlelit dinner", "rose petals", "bubble bath", "Netflix and chill" are overused
Partner text rules:
- Describe RECEIVER's experience clearly
- When initiator CAUSES a response (moan, cum, beg), frame as ALLOWING:
* text: "Make your partner moan" -> partner_text: "Let your partner make you moan"
* NOT: "Moan for your partner" (sounds forced, not natural)
</question_types>
Return a JSON object with a "suggestions" array containing ONLY questions that need improvement.
Each object should have:
- id: The question ID
- suggested_text: The improved text (short, direct, no parentheticals)
- suggested_partner_text: The improved partner_text (or null if symmetric)
- reason: Brief explanation of what was improved
<rules>
1. PRESERVE THE CORE ACTION - only improve phrasing, don't change what it's about
2. Keep SHORT and DIRECT - aim for 5-12 words, no parentheticals
3. Remove wishy-washy: "Would you want to...", "Have you ever...", "Do you think..."
4. Use "your partner" instead of "me", "you", "him", "her"
5. NEVER mix anatomically incompatible activities in same question
6. Make partner_text APPEALING - don't just grammatically flip, make receiver feel excited
7. FLAG CLICHES: "candlelit dinner", "rose petals", "bubble bath", "Netflix and chill"
8. Skip questions that are ALREADY well-phrased
</rules>
Questions:
${JSON.stringify(simplifiedQuestions)}`;
<intensity_guide>
Check if current intensity matches the activity:
1 (Gentle): Emotional bonding, non-sexual (cooking, cuddling, foot massage)
2 (Warm): Romantic, affectionate touch (slow dance, sensual massage, kissing)
3 (Playful): Light sexual exploration (oral, mutual masturbation, light roleplay)
4 (Steamy): Explicit sex, moderate adventure (intercourse, light bondage, anal play)
5 (Intense): Advanced/BDSM/extreme (impact play, power dynamics, taboo kinks)
If intensity seems wrong, include suggested_intensity and intensity_reason.
</intensity_guide>
<questions_to_analyze>
${JSON.stringify(simplifiedQuestions)}
</questions_to_analyze>
<output_format>
{
"suggestions": [
{
"id": string, // Question ID
"suggested_text": string, // Improved text (short, direct)
"suggested_partner_text": string|null, // Improved partner_text or null if symmetric
"reason": string, // Brief explanation of text improvement
"suggested_intensity": number|null, // 1-5 if intensity seems wrong, null if correct
"intensity_reason": string|null // Why intensity should change (if applicable)
}
]
}
Only include questions that need text improvement OR intensity adjustment.
</output_format>`;
const systemMessage = isExplicit
? 'You are an adult content editor for a couples intimacy app. Use nuanced language: tasteful phrasing for general acts (have sex, perform oral) but crude specific terms when relevant (cum, cock ring, etc). Preserve original intent. Always respond with valid JSON only.'
@@ -103,7 +129,7 @@ ${JSON.stringify(simplifiedQuestions)}`;
{ role: 'user', content: prompt }
],
response_format: { type: 'json_object' },
temperature: 0.5,
temperature: getTemperature('fix', 0.5),
});
const content = response.choices[0].message.content;
+25
View File
@@ -78,6 +78,31 @@ export function getModel(purpose?: ModelPurpose): string {
}
}
/**
* Get the appropriate temperature for a given purpose
* Uses remote config > default temperature > fallback
*/
export function getTemperature(purpose?: ModelPurpose, fallback?: number): number {
const remoteConfig = getCachedAiConfig();
const defaultTemperature = remoteConfig?.default_temperature;
const baseFallback = defaultTemperature ?? fallback ?? 0.7;
if (!purpose) return baseFallback;
switch (purpose) {
case 'generate':
return remoteConfig?.temperature_generate ?? defaultTemperature ?? fallback ?? 0.9;
case 'fix':
return remoteConfig?.temperature_fix ?? defaultTemperature ?? fallback ?? 0.5;
case 'polish':
return remoteConfig?.temperature_polish ?? defaultTemperature ?? fallback ?? 0.7;
case 'describe_image':
return remoteConfig?.classifier_temperature ?? defaultTemperature ?? fallback ?? 1.0;
default:
return baseFallback;
}
}
/**
* Extract a short display name from a model identifier
* e.g., "anthropic/claude-3.5-sonnet" -> "Claude 3.5 Sonnet"
+116 -66
View File
@@ -31,6 +31,7 @@ export function getCouncilConfig(): CouncilConfig {
enabled: remoteConfig.council_enabled || false,
generators,
reviewerModel: remoteConfig.council_reviewer_model || 'google/gemini-pro-1.5',
reviewerTemperature: remoteConfig.council_reviewer_temperature ?? 0.3,
selectionMode: remoteConfig.council_selection_mode || 'whole_set',
cherryPickEnsureIntensityDistribution: remoteConfig.cherry_pick_ensure_intensity_distribution ?? true,
};
@@ -41,6 +42,7 @@ export function getCouncilConfig(): CouncilConfig {
enabled: import.meta.env.VITE_COUNCIL_ENABLED === 'true',
generators: [{ model: import.meta.env.VITE_COUNCIL_GENERATOR_MODEL || 'anthropic/claude-3.5-sonnet' }],
reviewerModel: import.meta.env.VITE_COUNCIL_REVIEWER_MODEL || 'google/gemini-pro-1.5',
reviewerTemperature: 0.3,
selectionMode: 'whole_set',
cherryPickEnsureIntensityDistribution: true,
};
@@ -60,12 +62,11 @@ export async function initializeAiConfig(): Promise<AiConfig | null> {
// =============================================================================
export const TONE_LEVELS = [
{ level: 0, label: 'Everyday', description: 'Non-romantic activities - games, cooking, conversations' },
{ level: 1, label: 'Sweet', description: 'Romantic but innocent - compliments, date nights, cuddles' },
{ level: 2, label: 'Flirty', description: 'Playful teasing - flirty texts, light touching, innuendo' },
{ level: 3, label: 'Intimate', description: 'Sensual but soft - massage, making out, showering together' },
{ level: 4, label: 'Sexual', description: 'Standard sexual activities - oral, sex, toys' },
{ level: 5, label: 'Wild', description: 'Extreme/kinky - public sex, creampies, BDSM, exhibitionism' },
{ level: 1, label: 'Gentle', description: 'Pure emotional connection & non-sexual bonding' },
{ level: 2, label: 'Warm', description: 'Romantic atmosphere & affectionate touch' },
{ level: 3, label: 'Playful', description: 'Light sexual exploration & sensual discovery' },
{ level: 4, label: 'Steamy', description: 'Explicit sexual activities & moderate adventure' },
{ level: 5, label: 'Intense', description: 'Advanced/BDSM/Extreme exploration' },
] as const;
// =============================================================================
@@ -74,11 +75,11 @@ export const TONE_LEVELS = [
// =============================================================================
export const INTENSITY_LEVELS = [
{ level: 1, label: 'Light', description: 'Talking, emotional sharing, compliments, non-sexual', color: 'bg-green-500' },
{ level: 2, label: 'Mild', description: 'Hugging, cuddling, gentle kisses, flirty texts', color: 'bg-lime-500' },
{ level: 3, label: 'Moderate', description: 'Making out, massage, sexy/suggestive photos', color: 'bg-yellow-500' },
{ level: 4, label: 'Spicy', description: 'Oral, foreplay, nudes, explicit sexting', color: 'bg-orange-500' },
{ level: 5, label: 'Intense', description: 'Sex, kinks, BDSM, explicit videos', color: 'bg-red-500' },
{ level: 1, label: 'Gentle', description: 'Pure emotional connection & non-sexual bonding', color: 'bg-green-500' },
{ level: 2, label: 'Warm', description: 'Romantic atmosphere & affectionate touch', color: 'bg-lime-500' },
{ level: 3, label: 'Playful', description: 'Light sexual exploration & sensual discovery', color: 'bg-yellow-500' },
{ level: 4, label: 'Steamy', description: 'Explicit sexual activities & moderate adventure', color: 'bg-orange-500' },
{ level: 5, label: 'Intense', description: 'Advanced/BDSM/Extreme exploration', color: 'bg-red-500' },
] as const;
// =============================================================================
@@ -89,41 +90,30 @@ export const INTENSITY_LEVELS = [
export const INTENSITY_GUIDE = `
INTENSITY GRADING GUIDE - Use this to assign intensity levels consistently:
Level 1 - LIGHT (Emotional/Non-Sexual):
- Talking, sharing feelings, compliments, emotional vulnerability
- Non-physical activities: writing letters, planning dates, sharing dreams
- Clean activities with no sexual undertones
- Examples: "Share your biggest dream", "Write a love note", "Plan a surprise date"
Level 1 - GENTLE (Pure emotional connection & non-sexual bonding):
- Focus: Emotional safety, friendship-based activities, quality time
- Examples: Cook together, take a walk holding hands, give non-sexual foot massages, watch a movie cuddling
- Emotional bonding without sexual undertones
Level 2 - MILD (Affectionate/Flirty):
- Light physical affection: holding hands, hugging, cuddling
- Gentle kisses, playing with hair, light touches
- Flirty but non-sexual: dancing together, sleeping in each other's arms
- Examples: "Give your partner a forehead kiss", "Cuddle while watching a movie", "Send a flirty text"
Level 2 - WARM (Romantic atmosphere & affectionate touch):
- Focus: Creating romantic moments, sensual but non-sexual touch, building anticipation
- Examples: Slow dance in the living room, candlelit bath together (non-sexual), extended kissing sessions, sensual massage with oils
- Romantic intimacy building up to arousal
Level 3 - MODERATE (Sensual/Suggestive):
- Making out, passionate kissing, intimate massage
- Sensual touch over clothes, caressing, teasing
- Suggestive photos (lingerie, shirtless), sexy selfies, teasing texts
- Showering together, skinny dipping
- Examples: "Give a sensual massage", "Send a sexy photo", "Make out somewhere risky"
Level 3 - PLAYFUL (Light sexual exploration & sensual discovery):
- Focus: Sexual touch without penetration, playful experimentation, building arousal
- Examples: Mutual masturbation, oral sex, light roleplay (doctor/patient), using basic toys together
- Fun and exploratory sexual contact
Level 4 - SPICY (Sexual/Explicit Content):
- Oral sex, fingering, handjobs, mutual masturbation
- Foreplay activities, using hands/mouth on private parts
- Nudes, explicit photos, dirty pics, sexting explicit content
- Using toys externally, edging, making your partner cum
- Examples: "Go down on your partner", "Send a nude", "Sext each other something filthy"
Level 4 - STEAMY (Explicit sexual activities & moderate adventure):
- Focus: Full sexual intercourse with adventurous elements, moderate BDSM/kink
- Examples: Sex in different locations/positions, light bondage, anal play with toys, recording consensual intimate moments
- Passionate and adventurous sexual connection
Level 5 - INTENSE (Penetration/Advanced Kinks):
- Sex (vaginal or anal), toys inside, fingers inside
- Kinks: bondage, role-play with power dynamics, BDSM
- Threesomes, exhibitionism, voyeurism
- Making/sharing explicit videos, masturbating on video call
- Examples: "Fuck in a new location", "Try anal", "Record yourselves having sex"
IMPORTANT: Intensity is about SEXUAL CONTENT level, not just physical touch.
Sending nudes = Level 4, Sexting explicit content = Level 4, Dirty/filthy photos = Level 4.
Level 5 - INTENSE (Advanced/BDSM/Extreme exploration):
- Focus: Edge play, power dynamics, intense physical experiences
- Examples: Heavy impact play, advanced bondage, consensual non-consent scenes, multi-partner activities
- Very intense, taboo kinks, and extreme exploration
`;
// =============================================================================
@@ -133,13 +123,11 @@ Sending nudes = Level 4, Sexting explicit content = Level 4, Dirty/filthy photos
export const INTENSITY_GUIDE_SHORT = `
INTENSITY LEVELS:
1 (Light): Talking, emotional sharing, compliments, non-sexual activities
2 (Mild): Holding hands, hugging, cuddling, gentle kisses, flirty texts
3 (Moderate): Making out, sensual massage, sexy/suggestive photos, teasing
4 (Spicy): Oral sex, fingering, handjobs, nudes, explicit photos/sexting, foreplay
5 (Intense): Sex, anal, kinks, BDSM, explicit videos, threesomes
IMPORTANT: "Filthy photo", "nude", "dirty pic" = Level 4. "Sexy photo", "lingerie pic" = Level 3.
1 (Gentle): Pure emotional connection, non-sexual bonding (cooking, cuddling, foot massages)
2 (Warm): Romantic atmosphere, affectionate touch (slow dance, sensual massage, kissing)
3 (Playful): Light sexual exploration, sensual discovery (oral, mutual masturbation, light roleplay)
4 (Steamy): Explicit sexual activities, moderate adventure (sex positions, light bondage, anal play)
5 (Intense): Advanced/BDSM/Extreme exploration (impact play, power dynamics, taboo kinks)
`;
// =============================================================================
@@ -168,11 +156,11 @@ REVIEW CRITERIA - Score each 1-10:
- No confusing or ambiguous phrasing
4. INTENSITY ACCURACY:
Level 1 (Light): Talking, emotional sharing, compliments, non-sexual
Level 2 (Mild): Holding hands, hugging, cuddling, gentle kisses, flirty texts
Level 3 (Moderate): Making out, sensual massage, sexy/suggestive photos, teasing
Level 4 (Spicy): Oral sex, fingering, handjobs, nudes, explicit photos/sexting
Level 5 (Intense): Sex, anal, kinks, BDSM, explicit videos, threesomes
Level 1 (Gentle): Pure emotional connection, non-sexual bonding
Level 2 (Warm): Romantic atmosphere, affectionate touch
Level 3 (Playful): Light sexual exploration, sensual discovery (oral, toys)
Level 4 (Steamy): Explicit sexual activities, moderate adventure (intercourse, light bondage)
Level 5 (Intense): Advanced/BDSM/Extreme exploration (impact play, power dynamics)
5. ANATOMICAL CONSISTENCY:
- No mixed male/female anatomy in alternatives
@@ -184,6 +172,20 @@ REVIEW CRITERIA - Score each 1-10:
- BAD: "Receive oral from your partner"
- GOOD: "Let your partner pleasure you with their mouth"
- When initiator causes a response, frame as allowing: "Let your partner make you moan"
7. COUPLE TARGETING (flag if incorrect):
- Default: ALL couples (null) unless explicit anatomical requirement
- Restrict to ['male+male', 'female+male'] ONLY if activity requires penis
- Restrict to ['female+male', 'female+female'] ONLY if activity requires vagina
- Sex toys are GENDER-NEUTRAL (vibrators, dildos, plugs work for anyone)
- Flag if targeting seems too restrictive or missing needed restrictions
8. INITIATOR TARGETING (for asymmetric questions only):
- Default: null (anyone can initiate) unless explicit anatomical requirement
- Set initiator based on WHO DOES the action in "text" field
- "Swallow your partner's cum" -> initiator: female (in M+F, receiver of cum)
- "Give your partner a massage" -> initiator: null (anyone can give)
- Flag if initiator seems incorrectly assigned
`;
// =============================================================================
@@ -192,24 +194,72 @@ REVIEW CRITERIA - Score each 1-10:
// =============================================================================
export const TONE_INSTRUCTIONS: Record<ToneLevel, string> = {
0: 'WILDNESS LEVEL: EVERYDAY. Generate non-romantic activities only. Focus on communication, teamwork, fun, personal growth, and bonding WITHOUT any romantic or sexual undertones. Examples: "Cook a new recipe together", "Play a board game", "Send your partner a compliment while they\'re at work", "Plan a weekend adventure". NO flirting, NO romance, NO intimacy.',
1: 'WILDNESS LEVEL: SWEET. Generate romantic but innocent activities. Focus on emotional connection, love, and tenderness. Examples: "Write your partner a love letter", "Plan a surprise date night", "Cuddle and watch the sunset", "Give your partner a back massage". NO sexual content.',
2: 'WILDNESS LEVEL: FLIRTY. Generate playful, teasing activities with light innuendo. Examples: "Send a flirty text during the day", "Whisper something suggestive in your partner\'s ear", "Give your partner a lingering kiss goodbye". Suggestive but NOT explicitly sexual.',
3: 'WILDNESS LEVEL: INTIMATE. Generate sensual activities that imply intimacy without being graphic. Examples: "Give your partner a full-body massage", "Shower together", "Make out somewhere unexpected", "Sleep naked together". Passionate but soft - no explicit sex acts.',
4: 'WILDNESS LEVEL: SEXUAL. Generate standard sexual activities. Examples: "Go down on your partner", "Have sex in a new position", "Use a vibrator together", "Wake your partner up with oral". Direct language, avoid clinical terms. This is vanilla sex - adventurous but not extreme.',
5: 'WILDNESS LEVEL: WILD. Generate extreme, kinky, or taboo activities. Think: public sex, exhibitionism, BDSM, risky situations, intense kinks. Examples: "Get creampied by your partner and go to dinner", "Have sex somewhere you might get caught", "Let your partner tie you up and use you", "Edge each other for an hour before allowing release". Push boundaries. Use crude terms when they ARE the activity (cum, cock ring) but tasteful phrasing for general acts (have sex, not fuck). NEVER sanitize "cum" to "come".',
1: 'INTIMACY LEVEL: GENTLE. Focus on emotional safety, friendship-based activities, and quality time. Examples: "Cook a new recipe together", "Take a walk holding hands", "Give non-sexual foot massages", "Watch a movie cuddling". Pure emotional connection & non-sexual bonding.',
2: 'INTIMACY LEVEL: WARM. Focus on creating romantic moments, sensual but non-sexual touch, and building anticipation. Examples: "Slow dance in the living room", "Candlelit bath together (non-sexual)", "Extended kissing sessions", "Sensual massage with oils". Romantic atmosphere & affectionate touch.',
3: 'INTIMACY LEVEL: PLAYFUL. Focus on sexual touch without penetration, playful experimentation, and building arousal. Examples: "Mutual masturbation", "Oral sex", "Light roleplay (doctor/patient)", "Using basic toys together". Light sexual exploration & sensual discovery.',
4: 'INTIMACY LEVEL: STEAMY. Focus on full sexual intercourse with adventurous elements and moderate BDSM/kink. Examples: "Sex in different locations/positions", "Light bondage", "Anal play with toys", "Recording consensual intimate moments". Explicit sexual activities & moderate adventure.',
5: 'INTIMACY LEVEL: INTENSE. Focus on edge play, power dynamics, and intense physical experiences. Examples: "Heavy impact play", "Advanced bondage", "Consensual non-consent scenes", "Multi-partner activities". Advanced/BDSM/Extreme exploration.',
};
// =============================================================================
// CORE LANGUAGE RULES
// Consolidated rules used across all generators
// =============================================================================
export const CORE_LANGUAGE_RULES = `
<language_rules>
1. ALWAYS use "your partner" - NEVER use "me", "I", "you" (as the receiver), "him", "her", or gendered pronouns
2. Cards are PROPOSALS/ACTIVITIES, NOT interview questions - "Give your partner a massage" not "Would you like to give a massage?"
3. Avoid wishy-washy language - NO "Would you...", "Have you ever...", "Do you think...", "Maybe we could..."
4. No time-specific words - NO "tonight", "now", "today", "right now" - activities should be timeless
5. Maintain anatomical consistency - NEVER combine male-specific and female-specific acts as alternatives
6. Keep concise - ideal length is 5-12 words per question
</language_rules>
`;
// =============================================================================
// SYSTEM MESSAGES
// Per-tone-level system prompts for question generation
// =============================================================================
export const SYSTEM_MESSAGES: Record<ToneLevel, string> = {
0: 'You are a content writer for a couples app. Generate everyday activities focused on bonding, communication, and fun. No romance or intimacy. Always respond with valid JSON only.',
1: 'You are a content writer for a couples app. Generate sweet, romantic activities that help couples connect emotionally. Keep it innocent and wholesome. Always respond with valid JSON only.',
2: 'You are a content writer for a couples app. Generate flirty, playful activities with light teasing and innuendo. Suggestive but not sexual. Always respond with valid JSON only.',
3: 'You are a content writer for a couples app. Generate intimate, sensual activities that imply passion without being explicit. Always respond with valid JSON only.',
4: 'You are a content writer for an adult couples app. Generate sexual activities with direct, natural language. Write like a sex-positive friend - not clinical. Always respond with valid JSON only.',
5: 'You are a content writer for an adult couples app. Generate wild, extreme, kinky activities that push boundaries. Use crude terms when they ARE the act but tasteful phrasing for general sex/oral. Never use clinical terms. Always respond with valid JSON only.',
1: `You are an expert content writer for Sauci, a couples intimacy app where partners swipe on activity proposals.
<role>Generate gentle, emotionally focused activities for bonding.</role>
<constraint>NO sexual content whatsoever. Focus on emotional connection, quality time, and non-sexual touch.</constraint>
<style>Warm, caring, romantic. Activities should feel safe and nurturing.</style>
<format>Activities are proposals users swipe on (Like/Dislike/Maybe), not interview questions. Always respond with valid JSON only.</format>
<audience>Adults aged 25-45 in committed relationships.</audience>`,
2: `You are an expert content writer for Sauci, a couples intimacy app where partners swipe on activity proposals.
<role>Generate warm, romantic activities with affectionate touch.</role>
<constraint>Sensual but NOT explicit sex. Building anticipation without crossing into explicit territory.</constraint>
<style>Romantic, intimate, sensual. Create atmosphere and connection.</style>
<format>Activities are proposals users swipe on (Like/Dislike/Maybe), not interview questions. Always respond with valid JSON only.</format>
<audience>Adults aged 25-45 in committed relationships.</audience>`,
3: `You are an expert content writer for Sauci, a couples intimacy app where partners swipe on activity proposals.
<role>Generate playful, sexually exploratory activities.</role>
<constraint>Focus on foreplay, oral, toys, and light exploration. Avoid extreme kinks.</constraint>
<style>Playful, sexy, adventurous. Encourage exploration in a fun way.</style>
<format>Activities are proposals users swipe on (Like/Dislike/Maybe), not interview questions. Always respond with valid JSON only.</format>
<audience>Adults aged 25-45 in committed relationships.</audience>`,
4: `You are an expert content writer for Sauci, an adult couples intimacy app where partners swipe on activity proposals.
<role>Generate steamy, explicit sexual activities with moderate adventure.</role>
<constraint>Full sexual content allowed including intercourse, light bondage, and moderate kinks.</constraint>
<style>Passionate, explicit, adventurous. Bold but not extreme.</style>
<format>Activities are proposals users swipe on (Like/Dislike/Maybe), not interview questions. Always respond with valid JSON only.</format>
<audience>Adults aged 25-45 in committed relationships.</audience>`,
5: `You are an expert content writer for Sauci, an adult couples intimacy app where partners swipe on activity proposals.
<role>Generate intense, advanced sexual activities including BDSM and kinks.</role>
<constraint>Edge play, power dynamics, and intense physical experiences allowed. All content is consensual adult activity.</constraint>
<style>Intense, commanding, unapologetic. Push boundaries while maintaining consent.</style>
<format>Activities are proposals users swipe on (Like/Dislike/Maybe), not interview questions. Always respond with valid JSON only.</format>
<audience>Adults aged 25-45 in committed relationships seeking adventurous experiences.</audience>`,
};
+65 -24
View File
@@ -3,7 +3,7 @@
// Generate category names and category ideas
// =============================================================================
import { getOpenAI, getModel } from '../client';
import { getOpenAI, getModel, getTemperature } from '../client';
import type { GeneratedCategoryIdea } from '../types';
/**
@@ -11,19 +11,30 @@ import type { GeneratedCategoryIdea } from '../types';
*/
export async function generateCategory(): Promise<{ name: string; description: string; icon: string }> {
const openai = getOpenAI();
const prompt = `Generate a unique category for organizing activity packs in a couples' intimacy/connection app.
const prompt = `<task>
Generate a unique category for organizing activity packs in a couples' intimacy/connection app.
</task>
<app_context>
This is an ACTIVITY-FOCUSED app where couples swipe on activity proposals.
Each partner independently swipes Like/Dislike/Maybe, and when both swipe positively they "match".
Categories organize collections of activity packs (date ideas, intimate experiences, adventures, challenges, etc.).
This is NOT a Q&A app - it's about discovering shared interests in activities.
</app_context>
Return a JSON object with:
- name: Category name (1-3 words, e.g., "Romance", "Adventure", "Date Nights")
- description: Brief description of what activity packs in this category contain (1 sentence)
- icon: A single emoji that represents this category
<examples>
Good category names: "Romance", "Adventure", "Date Nights", "Intimacy", "Bonding", "Exploration"
</examples>
Be creative and think of categories that help couples explore different types of activities together.`;
<output_format>
{
"name": string, // 1-3 words (e.g., "Romance", "Adventure", "Date Nights")
"description": string, // 1 sentence describing what activity packs contain
"icon": string // Single emoji representing this category
}
Be creative and think of categories that help couples explore different types of activities together.
</output_format>`;
const response = await openai.chat.completions.create({
model: getModel('generate'),
@@ -35,7 +46,7 @@ Be creative and think of categories that help couples explore different types of
{ role: 'user', content: prompt },
],
response_format: { type: 'json_object' },
temperature: 0.9,
temperature: getTemperature('generate', 0.9),
});
const content = response.choices[0].message.content;
@@ -67,32 +78,62 @@ export async function suggestCategories(
? '\nCRUDE LANGUAGE OVERRIDE: Use crude, vulgar terms in the names and descriptions.'
: '';
const inspirationInstruction = inspiration
? `\n\nINSPIRATION/GUIDANCE FROM ADMIN:\n${inspiration}\n\nUse the above inspiration to guide your category suggestions.`
// Sanitize inspiration to prevent prompt injection
const sanitizedInspiration = inspiration
? inspiration
.replace(/<[^>]*>/g, '')
.replace(/\b(CRITICAL|PRIORITY|INSTRUCTION|OVERRIDE|IGNORE|SYSTEM|ASSISTANT)\b:?/gi, '')
.trim()
: '';
const avoidInstruction = previousSuggestions && previousSuggestions.length > 0
? `\n\nIMPORTANT - DO NOT suggest any of these category names (already suggested): ${previousSuggestions.join(', ')}\nGenerate completely different and fresh ideas.`
const inspirationSection = sanitizedInspiration
? `\n<user_guidance>\n${sanitizedInspiration}\n</user_guidance>`
: '';
const prompt = `Here are the current categories in our couples' activity/intimacy app: ${existingList}.
const avoidSection = previousSuggestions && previousSuggestions.length > 0
? `\n<avoid_names>\n${previousSuggestions.join(', ')}\n</avoid_names>`
: '';
IMPORTANT - HOW THE APP WORKS:
- This is an ACTIVITY-FOCUSED app where couples swipe on activity proposals
const prompt = `<task>
Suggest 5 NEW, UNIQUE category ideas for organizing activity packs in a couples' activity/intimacy app.
</task>
<existing_categories>
Current categories: ${existingList}
</existing_categories>
<app_context>
This is an ACTIVITY-FOCUSED app where couples swipe on activity proposals.
- Each partner independently swipes Like/Dislike/Maybe on activities
- When BOTH partners swipe positively, they "match" and can discuss the activity
- Categories organize collections of activity packs (date ideas, intimate experiences, adventures, etc.)
- This is NOT a Q&A app - it's about discovering shared interests in activities to do together
- This is NOT a Q&A app - it's about discovering shared interests in activities
</app_context>
${inspirationSection}
Suggest 5 NEW, UNIQUE category ideas that differ from the existing ones.
${explicitInstruction}${crudeLangInstruction}${inspirationInstruction}${avoidInstruction}
<content_guidelines>
${explicitInstruction}
${crudeLangInstruction}
</content_guidelines>
${avoidSection}
Return a JSON object with an "ideas" array containing 5 objects, where each object has:
- name: Category name (1-3 words)
- description: Brief description of what activity packs in this category contain (1 sentence)
- icon: A single descriptive emoji
<category_themes>
Focus on diverse themes: date ideas, adventures, intimate experiences, bonding activities, challenges, travel, home activities, etc.
</category_themes>
Focus on diverse activity themes like: date ideas, adventures, intimate experiences, bonding activities, challenges, travel, home activities, etc.`;
<output_format>
{
"ideas": [
{
"name": string, // 1-3 words
"description": string, // 1 sentence, what activity packs contain
"icon": string // Single descriptive emoji
}
]
}
Generate 5 unique categories that differ from existing ones.
</output_format>`;
const response = await openai.chat.completions.create({
model: getModel('generate'),
@@ -104,7 +145,7 @@ Focus on diverse activity themes like: date ideas, adventures, intimate experien
{ role: 'user', content: prompt },
],
response_format: { type: 'json_object' },
temperature: 0.9,
temperature: getTemperature('generate', 0.9),
});
const content = response.choices[0].message.content;
+63 -31
View File
@@ -38,73 +38,91 @@ export async function evaluateQuestionPool(
generatorIndex: q.sourceGeneratorIndex,
}));
const prompt = `You are evaluating a pool of ${pooledQuestions.length} questions from ${new Set(pooledQuestions.map(q => q.sourceGeneratorIndex)).size} different AI generators.
const prompt = `<task>
Evaluate a pool of ${pooledQuestions.length} questions from ${new Set(pooledQuestions.map(q => q.sourceGeneratorIndex)).size} different AI generators and select the best ones.
</task>
Your task is to:
<objectives>
1. Score each question individually (1-10 on each criterion)
2. Identify duplicates/semantically similar questions
3. Assess uniqueness relative to the pool
4. Consider pack theme fit
</objectives>
PACK CONTEXT:
<pack_context>
- Pack Name: "${packContext.name}"
${packContext.description ? `- Pack Description: "${packContext.description}"` : ''}
- Content Type: ${packContext.isExplicit ? 'EXPLICIT (adult content allowed)' : 'NON-EXPLICIT (clean/romantic only)'}
- Tone Level: ${packContext.tone} (${toneDescription})
- Requested question count: ${requestedCount}
</pack_context>
<scoring_criteria>
${REVIEW_GUIDELINES}
ADDITIONAL CRITERIA FOR CHERRY-PICK:
5. UNIQUENESS (1-10):
- How unique is this question compared to others in the pool?
- 10: Completely unique concept
- 10: Completely unique concept in the pool
- 7-9: Somewhat similar theme but distinct execution
- 4-6: Similar to another question but different enough to keep
- 1-3: Near-duplicate of another question
</scoring_criteria>
DUPLICATE DETECTION:
<scoring_weights>
Your scores will be weighted as follows for final selection:
- Guideline Compliance: 25%
- Creativity: 20%
- Clarity: 20%
- Uniqueness: 20%
- Intensity Accuracy: 15%
Calibrate your scores knowing these weights. A low compliance score has the most impact.
</scoring_weights>
<duplicate_detection>
Two questions are duplicates if they:
- Describe the same core activity/action
- Differ only in minor phrasing (e.g., "Have sex outdoors" vs "Make love outside")
- Describe the same core activity/action (e.g., "Have sex outdoors" vs "Make love outside")
- Differ only in minor phrasing variations
- Would feel repetitive if both appeared in the same pack
For duplicates, keep the version with:
For duplicates, mark the WORSE version as duplicate and keep the version with:
1. Better phrasing/creativity
2. More appropriate intensity grading
3. Better partner_text (if applicable)
</duplicate_detection>
VERDICT RULES:
- SELECT: High quality (avg score >= 7), no major issues - should be included
- CONSIDER: Medium quality (avg score 5-7) - include if needed to fill count
- SKIP: Low quality (avg score < 5) OR is a duplicate of a better question
<verdict_rules>
- SELECT: Average score >= 7, no major issues - should be included
- CONSIDER: Average score 5-7 - include if needed to fill count
- SKIP: Average score < 5 OR is a duplicate of a better question
</verdict_rules>
QUESTIONS TO EVALUATE:
<questions_to_evaluate>
${JSON.stringify(questionsForEval, null, 2)}
</questions_to_evaluate>
Return a JSON object with:
<output_format>
{
"evaluations": [
{
"questionIndex": 0,
"overallScore": 82,
"questionIndex": number, // 0-based index in the questions array
"overallScore": number, // Weighted score 0-100
"scores": {
"guidelineCompliance": 8,
"creativity": 9,
"clarity": 8,
"intensityAccuracy": 8,
"uniqueness": 9
"guidelineCompliance": 1-10,
"creativity": 1-10,
"clarity": 1-10,
"intensityAccuracy": 1-10,
"uniqueness": 1-10
},
"isDuplicate": false,
"duplicateOf": null,
"issues": ["Minor issue 1"],
"verdict": "select"
"isDuplicate": boolean,
"duplicateOf": number|null, // Index of the better version if duplicate
"issues": string[], // List of issues found
"verdict": "select"|"consider"|"skip"
}
]
}
Evaluate ALL ${pooledQuestions.length} questions.`;
Evaluate ALL ${pooledQuestions.length} questions.
</output_format>`;
const response = await openai.chat.completions.create({
model: config.reviewerModel,
@@ -116,7 +134,7 @@ Evaluate ALL ${pooledQuestions.length} questions.`;
{ role: 'user', content: prompt },
],
response_format: { type: 'json_object' },
temperature: 0.3,
temperature: config.reviewerTemperature ?? 0.3,
});
const content = response.choices[0].message.content;
@@ -280,7 +298,16 @@ export async function performCherryPickSelection(
index: i,
verdict: 'pass' as const,
issues: [],
scores: { guidelineCompliance: 7, creativity: 7, clarity: 7, intensityAccuracy: 7 },
scores: {
guidelineCompliance: 7,
creativity: 7,
clarity: 7,
intensityAccuracy: 7,
anatomicalConsistency: 10,
partnerTextQuality: 10,
coupleTargeting: 10,
initiatorTargeting: 10,
},
};
}
@@ -298,6 +325,11 @@ export async function performCherryPickSelection(
creativity: evaluation.scores.creativity,
clarity: evaluation.scores.clarity,
intensityAccuracy: evaluation.scores.intensityAccuracy,
// Cherry-pick doesn't evaluate these, default to passing
anatomicalConsistency: 10,
partnerTextQuality: 10,
coupleTargeting: 10,
initiatorTargeting: 10,
},
};
});
+2 -1
View File
@@ -143,7 +143,8 @@ export async function generateQuestionsWithCouncil(
packDescription,
existingQuestions,
crudeLang,
inspiration
inspiration,
gen.temperature
);
const result = {
generatorIndex: index,
+81 -56
View File
@@ -3,7 +3,7 @@
// Generate pack names, descriptions, and pack ideas
// =============================================================================
import { getOpenAI, getModel } from '../client';
import { getOpenAI, getModel, getTemperature } from '../client';
import type { GeneratedPack, GeneratedPackIdea } from '../types';
/**
@@ -26,48 +26,48 @@ export async function generatePack(
? 'CRUDE LANGUAGE OVERRIDE: Use crude, vulgar terms throughout - "fuck", "cock", "pussy" etc.'
: '';
const inspirationInstruction = inspiration
? `\n\nINSPIRATION/GUIDANCE FROM ADMIN:\n${inspiration}\n\nUse the above inspiration to guide your generation.`
// Sanitize inspiration to prevent prompt injection
const sanitizedInspiration = inspiration
? inspiration
.replace(/<[^>]*>/g, '')
.replace(/\b(CRITICAL|PRIORITY|INSTRUCTION|OVERRIDE|IGNORE|SYSTEM|ASSISTANT)\b:?/gi, '')
.trim()
: '';
const avoidInstruction = previousNames && previousNames.length > 0
? `\n\nIMPORTANT - DO NOT generate any of these pack names (already suggested): ${previousNames.join(', ')}\nGenerate something completely different and fresh.`
const inspirationSection = sanitizedInspiration
? `\n<user_guidance>\n${sanitizedInspiration}\n</user_guidance>`
: '';
const appContext = `This is an ACTIVITY-FOCUSED app where couples swipe on activity proposals.
const avoidSection = previousNames && previousNames.length > 0
? `\n<avoid_names>\n${previousNames.join(', ')}\n</avoid_names>`
: '';
const prompt = `<task>
Generate a creative activity pack for a couples' intimacy/connection app${categoryName ? ` in the category "${categoryName}"` : ''}.
</task>
<app_context>
This is an ACTIVITY-FOCUSED app where couples swipe on activity proposals.
Each partner independently swipes Like/Dislike/Maybe, and when both swipe positively they "match".
Packs contain collections of activities to try together (date ideas, intimate experiences, adventures, challenges, etc.).
This is NOT a Q&A app - it's about discovering shared interests in activities.`;
This is NOT a Q&A app - it's about discovering shared interests in activities.
</app_context>
${inspirationSection}
const prompt = categoryName
? `Generate a creative activity pack for a couples' intimacy/connection app in the category "${categoryName}".
<content_guidelines>
${explicitInstruction}
${crudeLangInstruction}
</content_guidelines>
${avoidSection}
${appContext}
<output_format>
{
"name": string, // Catchy pack name, 3-6 words, evokes activities/experiences
"description": string // Brief, enticing description, 1-2 sentences, activity-focused
}
The pack should contain a themed collection of activities couples can explore together.
${explicitInstruction}
${crudeLangInstruction}${inspirationInstruction}${avoidInstruction}
Return a JSON object with:
- name: A catchy, engaging pack name (3-6 words) that evokes activities/experiences
- description: A brief, enticing description (1-2 sentences) focusing on the activities in the pack
Make it romantic, playful, and activity-focused.`
: `Generate a creative activity pack for a couples' intimacy/connection app.
${appContext}
The pack should contain a themed collection of activities couples can explore together.
${explicitInstruction}
${crudeLangInstruction}${inspirationInstruction}${avoidInstruction}
Return a JSON object with:
- name: A catchy, engaging pack name (3-6 words) that evokes activities/experiences
- description: A brief, enticing description (1-2 sentences) focusing on the activities in the pack
Make it romantic, playful, and activity-focused.`;
Make it romantic, playful, and activity-focused.
</output_format>`;
const response = await openai.chat.completions.create({
model: getModel('generate'),
@@ -79,7 +79,7 @@ This is NOT a Q&A app - it's about discovering shared interests in activities.`;
{ role: 'user', content: prompt },
],
response_format: { type: 'json_object' },
temperature: 0.8,
temperature: getTemperature('generate', 0.8),
});
const content = response.choices[0].message.content;
@@ -112,42 +112,67 @@ export async function suggestPacks(
? '\nCRUDE LANGUAGE OVERRIDE: Use crude, vulgar terms in the pack names and descriptions.'
: '';
const inspirationInstruction = inspiration
? `\n\nINSPIRATION/GUIDANCE FROM ADMIN:\n${inspiration}\n\nUse the above inspiration to guide your pack suggestions.`
// Sanitize inspiration to prevent prompt injection
const sanitizedInspiration = inspiration
? inspiration
.replace(/<[^>]*>/g, '')
.replace(/\b(CRITICAL|PRIORITY|INSTRUCTION|OVERRIDE|IGNORE|SYSTEM|ASSISTANT)\b:?/gi, '')
.trim()
: '';
const avoidInstruction = previousSuggestions && previousSuggestions.length > 0
? `\n\nIMPORTANT - DO NOT suggest any of these pack names (already suggested): ${previousSuggestions.join(', ')}\nGenerate completely different and fresh ideas.`
const inspirationSection = sanitizedInspiration
? `\n<user_guidance>\n${sanitizedInspiration}\n</user_guidance>`
: '';
const prompt = `We are building activity packs for the category "${categoryName}" in a couples' intimacy app.
const avoidSection = previousSuggestions && previousSuggestions.length > 0
? `\n<avoid_names>\n${previousSuggestions.join(', ')}\n</avoid_names>`
: '';
IMPORTANT - HOW THE APP WORKS:
- This is an ACTIVITY-FOCUSED app, NOT a Q&A app
const prompt = `<task>
Suggest 5 NEW, UNIQUE activity pack ideas for the category "${categoryName}" in a couples' intimacy app.
</task>
<app_context>
This is an ACTIVITY-FOCUSED app, NOT a Q&A app.
- Packs contain ACTIVITY PROPOSALS (things couples can do together)
- Each partner independently swipes Like/Dislike/Maybe on activities
- When BOTH partners swipe positively on the same activity, they "match" and can discuss it
- Activities are things like: date ideas, intimate experiences, adventures, challenges, conversations to have, etc.
- This is NOT about asking each other questions - it's about discovering shared interests in activities
- When BOTH partners swipe positively, they "match" and can discuss it
- Activities include: date ideas, intimate experiences, adventures, challenges, conversations
- Focus on discovering shared interests in activities, not asking questions
</app_context>
${inspirationSection}
PACK THEMES should focus on:
<pack_themes>
Focus on:
- Collections of related activities couples might want to try
- Experiences to share together
- Things to do, not questions to ask
- Date ideas, adventures, intimate moments, challenges, bonding activities
</pack_themes>
Existing packs in this category: ${existingList}.
<existing_packs>
Current packs in this category: ${existingList}
</existing_packs>
Suggest 5 NEW, UNIQUE pack ideas that fit this category and differ from existing ones.
${explicitInstruction}${crudeLangInstruction}${inspirationInstruction}${avoidInstruction}
<content_guidelines>
${explicitInstruction}
${crudeLangInstruction}
</content_guidelines>
${avoidSection}
Return a JSON object with an "ideas" array containing 5 objects, where each object has:
- name: Pack name (catchy, 3-6 words) - should evoke activities/experiences, not questions
- description: Brief description (1-2 sentences) focusing on the activities/experiences in the pack
- icon: An Ionicon name from this list: heart-outline, flame-outline, sparkles-outline, gift-outline, wine-outline, airplane-outline, home-outline, key-outline, flash-outline, sunny-outline, flower-outline, star-outline, dice-outline, compass-outline, bulb-outline
<output_format>
{
"ideas": [
{
"name": string, // Catchy, 3-6 words, evokes activities/experiences
"description": string, // 1-2 sentences, activity-focused
"icon": string // Ionicon: heart-outline, flame-outline, sparkles-outline, gift-outline, wine-outline, airplane-outline, home-outline, key-outline, flash-outline, sunny-outline, flower-outline, star-outline, dice-outline, compass-outline, bulb-outline
}
]
}
Make them engaging and specific to "${categoryName}".`;
Generate 5 unique pack ideas that differ from existing ones. Make them engaging and specific to "${categoryName}".
</output_format>`;
const response = await openai.chat.completions.create({
model: getModel('generate'),
@@ -159,7 +184,7 @@ Make them engaging and specific to "${categoryName}".`;
{ role: 'user', content: prompt },
],
response_format: { type: 'json_object' },
temperature: 0.9,
temperature: getTemperature('generate', 0.9),
});
const content = response.choices[0].message.content;
+91 -50
View File
@@ -3,8 +3,8 @@
// Generate questions for packs with various tone/intensity settings
// =============================================================================
import { getOpenAI, getModel } from '../client';
import { INTENSITY_GUIDE_SHORT, TONE_INSTRUCTIONS, SYSTEM_MESSAGES } from '../config';
import { getOpenAI, getModel, getTemperature } from '../client';
import { INTENSITY_GUIDE_SHORT, TONE_INSTRUCTIONS, SYSTEM_MESSAGES, CORE_LANGUAGE_RULES } from '../config';
import type { GeneratedQuestion, ToneLevel } from '../types';
/**
@@ -20,53 +20,59 @@ function buildQuestionPrompt(
crudeLang: boolean = false,
inspiration?: string
): string {
const isClean = tone === 0;
const isExplicit = tone >= 4;
const crudeLangInstruction = crudeLang
? '\n\nCRUDE LANGUAGE OVERRIDE: Ignore nuanced language rules. Use crude, vulgar terms throughout - "fuck" instead of "have sex", "suck cock" instead of "perform oral", etc. Be raw and direct like uncensored sexting.'
: '';
const inspirationInstruction = inspiration
? `\n\nINSPIRATION/GUIDANCE FROM ADMIN:\n${inspiration}\n\nUse the above inspiration to guide the types of questions you generate.`
// Sanitize inspiration to prevent prompt injection (remove XML-like tags and authority phrases)
const sanitizedInspiration = inspiration
? inspiration
.replace(/<[^>]*>/g, '') // Remove XML-like tags
.replace(/\b(CRITICAL|PRIORITY|INSTRUCTION|OVERRIDE|IGNORE|SYSTEM|ASSISTANT)\b:?/gi, '') // Remove authority words
.trim()
: '';
const intensityInstruction = isClean
? 'All questions should be intensity level 1 (non-physical activities).'
const inspirationInstruction = sanitizedInspiration
? `\n\n<user_guidance>\n${sanitizedInspiration}\n</user_guidance>\n\nIncorporate the user guidance above into your generation where appropriate. Use it to inform the theme and style of questions.`
: '';
const intensityInstruction = tone === 1
? 'All questions should be intensity level 1 (non-sexual bonding).'
: intensity
? `All questions should be at intensity level ${intensity}.`
: 'Vary the intensity levels from 1 to 5 across the questions for good variety.';
: 'Vary the intensity levels appropriate for the tone.';
const toneInstruction = TONE_INSTRUCTIONS[tone];
// Examples based on tone level
const symmetricExamples = isClean
? 'GOOD: "Cook a new recipe together", "Play a board game", "Go on a hike", "Have a deep conversation about your goals".'
const symmetricExamples = tone <= 2
? 'GOOD: "Cook a new recipe together", "Take a walk holding hands", "Slow dance in the living room", "Sensual massage".'
: isExplicit
? 'GOOD: "Fuck in a risky place", "Try a new position", "Sixty-nine together", "Watch porn and recreate a scene".'
: 'GOOD: "Cook a romantic dinner together", "Stargaze and share dreams", "Give each other massages", "Dance together at home".';
? 'GOOD: "Sex in different positions", "Light bondage", "Anal play with toys", "Record intimate moments".'
: 'GOOD: "Mutual masturbation", "Oral sex", "Light roleplay", "Using basic toys together".';
const asymmetricExamples = isClean
const asymmetricExamples = tone <= 2
? `Examples:
- text: "Cook your partner their favorite meal" → partner_text: "Have your partner cook your favorite meal"
- text: "Plan a surprise for your partner" → partner_text: "Be surprised by your partner"
- text: "Give your partner a compliment" → partner_text: "Receive a compliment from your partner"`
- text: "Give your partner a massage" → partner_text: "Receive a massage from your partner"
- text: "Plan a surprise for your partner" → partner_text: "Be surprised by your partner"`
: isExplicit
? `Examples:
- text: "Go down on your partner" → partner_text: "Have your partner go down on you"
- text: "Send your partner a nude" → partner_text: "Receive a nude from your partner"
- text: "Tie your partner up" → partner_text: "Get tied up by your partner"
- text: "Spank your partner" → partner_text: "Get spanked by your partner"`
- text: "Spank your partner" → partner_text: "Get spanked by your partner"
- text: "Use a toy on your partner" → partner_text: "Have a toy used on you"`
: `Examples:
- text: "Give your partner a massage" → partner_text: "Receive a massage from your partner"
- text: "Write your partner a love letter" → partner_text: "Receive a love letter from your partner"
- text: "Plan a surprise date for your partner" → partner_text: "Be surprised with a date by your partner"`;
- text: "Perform oral on your partner" → partner_text: "Receive oral from your partner"
- text: "Tease your partner" → partner_text: "Be teased by your partner"
- text: "Undress your partner" → partner_text: "Be undressed by your partner"`;
const explicitWarning = isClean
? '\n\nCRITICAL: This is a CLEAN pack with NO romantic or sexual content. Focus ONLY on communication, activities, challenges, and bonding. NO romance, NO flirting, NO intimacy, NO physical affection beyond friendly gestures.'
const explicitWarning = tone === 1
? '\n\nCRITICAL: This is a GENTLE pack. Focus on emotional connection and non-sexual bonding. NO explicit sexual acts.'
: isExplicit
? ''
: '\n\nCRITICAL: This is a NON-EXPLICIT pack. Do NOT include any sexual acts, crude language, or NSFW content. Keep all proposals romantic, playful, or emotionally intimate without being sexually explicit.';
: '\n\nCRITICAL: Avoid extreme kinks or hardcore content unless specifically requested.';
const descriptionContext = packDescription
? `\nPack Description: "${packDescription}"\nUse this description to guide the theme and style of questions.`
@@ -79,43 +85,77 @@ ${existingQuestions.map((q, i) => `${i + 1}. ${q}`).join('\n')}
CRITICAL: Generate completely NEW and DIFFERENT questions. Do not repeat any of the above questions or create minor variations of them. Each new question must offer a genuinely distinct activity or experience.`
: '';
return `Generate ${count} unique questions for a couples' intimacy question pack called "${packName}".${descriptionContext}${existingQuestionsContext}
return `<task>
Generate ${count} unique questions for a couples' intimacy question pack called "${packName}".
</task>
${descriptionContext}${inspirationInstruction}${existingQuestionsContext}
<intensity_guide>
${INTENSITY_GUIDE_SHORT}
</intensity_guide>
<instructions>
${intensityInstruction}
${toneInstruction}${explicitWarning}${crudeLangInstruction}${inspirationInstruction}
${toneInstruction}${explicitWarning}${crudeLangInstruction}
</instructions>
IMPORTANT: The app uses a swipe-based interface (Like/Dislike/Maybe).
Cards should be "Proposals" for specific actions, NOT interview questions.
${CORE_LANGUAGE_RULES}
CRITICAL LANGUAGE RULES:
- ALWAYS use "your partner" - NEVER use "me", "I", "you" (as the receiver), "him", "her"
- BAD: "Send me a photo" (who is "me"?), "Let me spank you", "I want you to..."
- GOOD: "Send your partner a photo", "Spank your partner", "Give your partner..."
- The card reader is the DOER. Their partner is "your partner".
<examples>
GOOD language: "Send your partner a photo", "Spank your partner", "Give your partner..."
BAD language: "Send me a photo" (who is "me"?), "Let me spank you", "I want you to..."
The card reader is the DOER. Their partner is "your partner".
</examples>
For each question, decide if it is SYMMETRIC (shared) or ASYMMETRIC (one does to the other):
1. SYMMETRIC Activities (partner_text = null):
- Both partners do the same thing together.
<question_types>
1. SYMMETRIC Activities (partner_text = null, no inverse_pair_id):
- Both partners do the same thing together
- ${symmetricExamples}
2. ASYMMETRIC Actions (requires partner_text):
- One partner does something TO/FOR the other, or roles differ.
2. ASYMMETRIC Actions (requires partner_text AND inverse pair):
- One partner does something TO/FOR the other, or roles differ
- text = what the INITIATOR does
- partner_text = what the RECEIVER does/experiences
- ${asymmetricExamples}
- CRITICAL: Every asymmetric question MUST have its inverse created as a separate question
</question_types>
Required JSON structure:
- text: The proposal (DOER's perspective, uses "your partner")
- partner_text: For asymmetric only - RECEIVER's perspective
- intensity: 1-5 based on INTENSITY LEVELS above.
- requires_props: (optional) Array of items needed
- location_type: (optional) "home" | "public" | "outdoors" | "travel" | "anywhere"
- effort_level: (optional) "spontaneous" | "low" | "medium" | "planned"
<inverse_pairs>
For EVERY asymmetric question, you MUST create its inverse as a separate question.
Both questions in a pair should have the SAME inverse_pair_id (a unique string per pair).
Return a JSON object with a "questions" array.`;
Example pair (same inverse_pair_id "pair_1"):
Question 1 (primary):
text: "Spank your partner"
partner_text: "Be spanked by your partner"
inverse_pair_id: "pair_1"
Question 2 (inverse):
text: "Be spanked by your partner"
partner_text: "Spank your partner"
inverse_pair_id: "pair_1"
The first question in each pair (lower array index) becomes the "primary" and the second gets linked as its inverse.
Use simple pair IDs like "pair_1", "pair_2", etc.
Symmetric questions should have inverse_pair_id: null.
</inverse_pairs>
<output_format>
Return a JSON object with this exact structure:
{
"questions": [
{
"text": string, // REQUIRED: 5-12 words, doer's perspective using "your partner"
"partner_text": string|null, // REQUIRED for asymmetric, null for symmetric
"intensity": 1-5, // REQUIRED: must match the intensity level guidelines
"requires_props": string[]|null, // Optional: items needed (e.g., ["blindfold", "massage oil"])
"inverse_pair_id": string|null, // REQUIRED for asymmetric pairs, null for symmetric
"location_type": "home"|"public"|"outdoors"|"travel"|"anywhere", // Optional
"effort_level": "spontaneous"|"low"|"medium"|"planned" // Optional
}
]
}
</output_format>`;
}
/**
@@ -154,7 +194,7 @@ export async function generateQuestions(
{ role: 'user', content: prompt },
],
response_format: { type: 'json_object' },
temperature: 0.9,
temperature: getTemperature('generate', 0.9),
});
const content = response.choices[0].message.content;
@@ -177,7 +217,8 @@ export async function generateQuestionsWithModel(
packDescription: string | undefined,
existingQuestions: string[] | undefined,
crudeLang: boolean,
inspiration: string | undefined
inspiration: string | undefined,
temperature?: number
): Promise<GeneratedQuestion[]> {
const openai = getOpenAI();
@@ -202,7 +243,7 @@ export async function generateQuestionsWithModel(
{ role: 'user', content: prompt },
],
response_format: { type: 'json_object' },
temperature: 0.9,
temperature: temperature ?? getTemperature('generate', 0.9),
});
const content = response.choices[0].message.content;
+4
View File
@@ -25,6 +25,8 @@ export type {
CherryPickResult,
TargetAnalysis,
TextAnalysis,
DeletionAnalysis,
PropsAnalysis,
ExtractedTopic,
TopicExtractionResult,
SelectionResult,
@@ -59,6 +61,8 @@ export { reviewGeneratedQuestions, selectBestGeneration } from './review';
// Analyzers
export { analyzeQuestionText } from './analyzers/text';
export { analyzeQuestionTargets } from './analyzers/targets';
export { analyzeQuestionDeletions } from './analyzers/deletions';
export { analyzeQuestionProps } from './analyzers/props';
// Tools
export { polishContent } from './tools/polish';
+121 -55
View File
@@ -39,47 +39,68 @@ export async function reviewGeneratedQuestions(
intensity: q.intensity,
location_type: q.location_type,
effort_level: q.effort_level,
// Include targeting for review (may not always be present)
allowed_couple_genders: (q as { allowed_couple_genders?: string[] | null }).allowed_couple_genders || null,
target_user_genders: (q as { target_user_genders?: string[] | null }).target_user_genders || null,
}));
const toneDescription = TONE_LEVELS.find(t => t.level === packContext.tone)?.label || 'Unknown';
const prompt = `You are a quality reviewer for a couples intimacy app. Review each generated question against our guidelines.
const prompt = `<task>
Review each generated question against our quality guidelines.
</task>
PACK CONTEXT:
<pack_context>
- Pack Name: "${packContext.name}"
${packContext.description ? `- Pack Description: "${packContext.description}"` : ''}
- Content Type: ${packContext.isExplicit ? 'EXPLICIT (adult content allowed)' : 'NON-EXPLICIT (clean/romantic only)'}
- Tone Level: ${packContext.tone} (${toneDescription})
</pack_context>
<scoring_criteria>
${REVIEW_GUIDELINES}
</scoring_criteria>
VERDICT RULES:
<verdict_rules>
- PASS: All scores >= 7, no major issues
- FLAG: Any score 5-7, or minor issues worth noting (admin should review but can use)
- REJECT: Any score < 5, or major violations (mixed anatomy, wrong intensity, severe guideline violation)
</verdict_rules>
QUESTIONS TO REVIEW:
<questions_to_review>
${JSON.stringify(questionsForReview, null, 2)}
</questions_to_review>
Return a JSON object with:
<output_format>
{
"reviews": [
{
"index": 0,
"verdict": "pass" | "flag" | "reject",
"issues": ["Issue 1", "Issue 2"],
"suggestions": "Optional improvement suggestion",
"index": number, // 0-based index of the question
"verdict": "pass"|"flag"|"reject",
"issues": string[], // List of issues found, empty if none
"suggestions": string|null, // Optional improvement suggestion
"scores": {
"guidelineCompliance": 8,
"creativity": 7,
"clarity": 9,
"intensityAccuracy": 8
}
"guidelineCompliance": 1-10, // Criterion 1
"creativity": 1-10, // Criterion 2
"clarity": 1-10, // Criterion 3
"intensityAccuracy": 1-10, // Criterion 4
"anatomicalConsistency": 1-10, // Criterion 5
"partnerTextQuality": 1-10, // Criterion 6 (10 if no partner_text needed)
"coupleTargeting": 1-10, // Criterion 7
"initiatorTargeting": 1-10 // Criterion 8 (10 if symmetric)
},
"intensitySuggestion": number|null, // Suggested intensity (1-5) if current seems wrong, null if correct
"targetingSuggestions": { // Only include if targeting needs changes
"suggestedCoupleTargets": string[]|null, // ["male+male", "female+male", "female+female"] or subset
"suggestedInitiator": string[]|null, // ["male", "female"] or null for any
"reason": string // Why targeting should change
}|null
}
]
}
Review ALL questions. Be thorough but fair - only flag/reject for genuine issues.`;
Review ALL questions against ALL 8 criteria. Be thorough but fair - only flag/reject for genuine issues.
</output_format>`;
const response = await openai.chat.completions.create({
model: config.reviewerModel,
@@ -91,7 +112,7 @@ Review ALL questions. Be thorough but fair - only flag/reject for genuine issues
{ role: 'user', content: prompt },
],
response_format: { type: 'json_object' },
temperature: 0.3,
temperature: config.reviewerTemperature ?? 0.3,
});
const content = response.choices[0].message.content;
@@ -111,19 +132,31 @@ Review ALL questions. Be thorough but fair - only flag/reject for genuine issues
const flagged = reviews.filter(r => r.verdict === 'flag').length;
const rejected = reviews.filter(r => r.verdict === 'reject').length;
// Calculate overall quality as average of all scores
let totalScore = 0;
let scoreCount = 0;
// Calculate overall quality as weighted average of all 8 scores
// Weights: guidelineCompliance 20%, creativity 15%, clarity 15%, intensityAccuracy 15%,
// anatomicalConsistency 15%, partnerTextQuality 10%, coupleTargeting 5%, initiatorTargeting 5%
let totalWeightedScore = 0;
let totalWeight = 0;
const weights = {
guidelineCompliance: 0.20,
creativity: 0.15,
clarity: 0.15,
intensityAccuracy: 0.15,
anatomicalConsistency: 0.15,
partnerTextQuality: 0.10,
coupleTargeting: 0.05,
initiatorTargeting: 0.05,
};
for (const review of reviews) {
if (review.scores) {
totalScore += review.scores.guidelineCompliance || 0;
totalScore += review.scores.creativity || 0;
totalScore += review.scores.clarity || 0;
totalScore += review.scores.intensityAccuracy || 0;
scoreCount += 4;
for (const [key, weight] of Object.entries(weights)) {
const score = review.scores[key as keyof typeof review.scores] || 0;
totalWeightedScore += score * weight;
totalWeight += weight;
}
}
const overallQuality = scoreCount > 0 ? Math.round((totalScore / scoreCount) * 10) / 10 : 0;
}
const overallQuality = totalWeight > 0 ? Math.round((totalWeightedScore / (totalWeight / Object.keys(weights).length)) * 10) / 10 : 0;
return {
reviews,
@@ -168,54 +201,77 @@ export async function selectBestGeneration(
text: q.text,
partner_text: q.partner_text || null,
intensity: q.intensity,
allowed_couple_genders: (q as { allowed_couple_genders?: string[] | null }).allowed_couple_genders || null,
target_user_genders: (q as { target_user_genders?: string[] | null }).target_user_genders || null,
})),
}));
const prompt = `You are a quality reviewer for a couples intimacy app. You have received ${candidates.length} different sets of generated questions from different AI models. Your task is to:
const prompt = `<task>
Compare ${candidates.length} different sets of generated questions from different AI models. Select the best set and review it in detail.
</task>
<objectives>
1. COMPARE all sets and SELECT the BEST one overall
2. REVIEW the selected set in detail
</objectives>
PACK CONTEXT:
<pack_context>
- Pack Name: "${packContext.name}"
${packContext.description ? `- Pack Description: "${packContext.description}"` : ''}
- Content Type: ${packContext.isExplicit ? 'EXPLICIT (adult content allowed)' : 'NON-EXPLICIT (clean/romantic only)'}
- Tone Level: ${packContext.tone} (${toneDescription})
</pack_context>
<scoring_criteria>
${REVIEW_GUIDELINES}
</scoring_criteria>
CANDIDATE SETS:
<candidate_sets>
${JSON.stringify(candidatesForReview, null, 2)}
</candidate_sets>
SELECTION CRITERIA (in order of importance):
1. Overall quality and creativity of questions
2. Variety and uniqueness within the set
3. Adherence to pack theme and tone
4. Proper intensity grading
5. Correct use of "your partner" language
6. Quality of partner_text for asymmetric questions
<selection_criteria>
Ranked by importance (approximate weights):
1. Overall quality and creativity of questions (30%)
2. Variety and uniqueness within the set (20%)
3. Adherence to pack theme and tone (20%)
4. Proper intensity grading (15%)
5. Correct use of "your partner" language (10%)
6. Quality of partner_text for asymmetric questions (5%)
</selection_criteria>
Return a JSON object with:
<output_format>
{
"selectedIndex": <0-based index of the best candidate set>,
"reasoning": "<brief explanation of why this set was chosen>",
"selectedIndex": number, // 0-based index of the best candidate set
"reasoning": string, // Brief explanation of why this set was chosen
"reviews": [
{
"index": <question index within selected set>,
"verdict": "pass" | "flag" | "reject",
"issues": ["Issue 1", "Issue 2"],
"suggestions": "Optional improvement suggestion",
"index": number, // Question index within selected set
"verdict": "pass"|"flag"|"reject",
"issues": string[],
"suggestions": string|null,
"scores": {
"guidelineCompliance": 1-10,
"creativity": 1-10,
"clarity": 1-10,
"intensityAccuracy": 1-10
}
"intensityAccuracy": 1-10,
"anatomicalConsistency": 1-10,
"partnerTextQuality": 1-10,
"coupleTargeting": 1-10,
"initiatorTargeting": 1-10
},
"intensitySuggestion": number|null,
"targetingSuggestions": {
"suggestedCoupleTargets": string[]|null,
"suggestedInitiator": string[]|null,
"reason": string
}|null
}
]
}
Select the best set and review ALL questions in that set.`;
Select the best set and review ALL questions in that set against ALL 8 criteria.
</output_format>`;
const response = await openai.chat.completions.create({
model: config.reviewerModel,
@@ -227,7 +283,7 @@ Select the best set and review ALL questions in that set.`;
{ role: 'user', content: prompt },
],
response_format: { type: 'json_object' },
temperature: 0.3,
temperature: config.reviewerTemperature ?? 0.3,
});
const content = response.choices[0].message.content;
@@ -244,23 +300,33 @@ Select the best set and review ALL questions in that set.`;
const reviews: QuestionReview[] = parsed.reviews || [];
const reasoning = parsed.reasoning || 'No reasoning provided';
// Calculate summary
// Calculate summary with weighted average of all 8 scores
const passed = reviews.filter(r => r.verdict === 'pass').length;
const flagged = reviews.filter(r => r.verdict === 'flag').length;
const rejected = reviews.filter(r => r.verdict === 'reject').length;
let totalScore = 0;
let scoreCount = 0;
let totalWeightedScore = 0;
let totalWeight = 0;
const weights = {
guidelineCompliance: 0.20,
creativity: 0.15,
clarity: 0.15,
intensityAccuracy: 0.15,
anatomicalConsistency: 0.15,
partnerTextQuality: 0.10,
coupleTargeting: 0.05,
initiatorTargeting: 0.05,
};
for (const review of reviews) {
if (review.scores) {
totalScore += review.scores.guidelineCompliance || 0;
totalScore += review.scores.creativity || 0;
totalScore += review.scores.clarity || 0;
totalScore += review.scores.intensityAccuracy || 0;
scoreCount += 4;
for (const [key, weight] of Object.entries(weights)) {
const score = review.scores[key as keyof typeof review.scores] || 0;
totalWeightedScore += score * weight;
totalWeight += weight;
}
}
const overallQuality = scoreCount > 0 ? Math.round((totalScore / scoreCount) * 10) / 10 : 0;
}
const overallQuality = totalWeight > 0 ? Math.round((totalWeightedScore / (totalWeight / Object.keys(weights).length)) * 10) / 10 : 0;
return {
selectedIndex,
+23 -11
View File
@@ -3,7 +3,7 @@
// Polish and improve content text
// =============================================================================
import { getOpenAI, getModel } from '../client';
import { getOpenAI, getModel, getTemperature } from '../client';
type PolishType = 'question' | 'partner_text' | 'pack_name' | 'pack_description' | 'category_name';
@@ -64,20 +64,32 @@ export async function polishContent(
`;
}
const prompt = `Please polish, improve, and tidy up the following text, which is used as ${contextMap[type] || 'text in the app'}.
const prompt = `<task>
Polish, improve, and tidy up the following text used as ${contextMap[type] || 'text in the app'}.
</task>
Original text: "${text}"
<original_text>
"${text}"
</original_text>
${explicitInstruction}
<content_type>
${explicitInstruction}
</content_type>
${additionalRules}
${fewShotExamples}
<rules>
${additionalRules}
- Make it concise, engaging, and grammatically correct
- Maintain the original intent and meaning
- Do NOT change the core action - only improve phrasing
</rules>
Make it concise, engaging, and grammatically correct.
Maintain the original intent and meaning.
${fewShotExamples}
Return a JSON object with:
- polished: The improved text string`;
<output_format>
{
"polished": string // The improved text
}
</output_format>`;
const response = await openai.chat.completions.create({
model: getModel('polish'),
@@ -89,7 +101,7 @@ export async function polishContent(
{ role: 'user', content: prompt },
],
response_format: { type: 'json_object' },
temperature: 0.7,
temperature: getTemperature('polish', 0.7),
});
const content = response.choices[0].message.content;
+35 -22
View File
@@ -3,7 +3,7 @@
// Extract topics from packs for categorization
// =============================================================================
import { getOpenAI, getModel } from '../client';
import { getOpenAI, getModel, getTemperature } from '../client';
import type { TopicExtractionResult } from '../types';
/**
@@ -20,34 +20,47 @@ export async function extractTopicsFromPack(
const existingTopicNames = existingTopics.map(t => t.name);
const existingTopicsJson = JSON.stringify(existingTopics);
const prompt = `Analyze the following question pack and extract relevant topics/kinks/interests that describe its content.
const prompt = `<task>
Analyze the question pack and extract relevant topics/kinks/interests that describe its content.
</task>
PACK NAME: "${packName}"
${packDescription ? `PACK DESCRIPTION: "${packDescription}"` : ''}
<pack_info>
Pack Name: "${packName}"
${packDescription ? `Pack Description: "${packDescription}"` : ''}
</pack_info>
QUESTIONS IN THIS PACK:
<questions>
${questions.map((q, i) => `${i + 1}. ${q}`).join('\n')}
</questions>
EXISTING TOPICS IN THE SYSTEM (prefer matching these when appropriate):
${existingTopicNames.length > 0 ? existingTopicNames.join(', ') : 'None yet'}
<existing_topics>
Available topics (prefer matching these): ${existingTopicNames.length > 0 ? existingTopicNames.join(', ') : 'None yet'}
EXISTING TOPICS WITH IDs:
${existingTopicsJson}
Topic IDs: ${existingTopicsJson}
</existing_topics>
INSTRUCTIONS:
<instructions>
1. Identify 1-5 topics that best describe this pack's content
2. Topics should be specific kinks, interests, or themes (e.g., "Bondage", "Exhibitionism", "Voyeurism", "Role Play", "Sensory Play", "Oral", "Anal", "Communication", "Romance", "Adventure")
3. ALWAYS prefer matching an existing topic if one fits - use the EXACT name and include the existingTopicId
4. Only suggest a new topic if nothing in the existing list is appropriate
5. Topic names should be title case (e.g., "Bondage" not "bondage")
6. Keep topics broad enough to be reusable across packs
2. Topics should be specific kinks, interests, or themes
- Examples: Bondage, Exhibitionism, Voyeurism, Role Play, Sensory Play, Oral, Anal, Communication, Romance, Adventure
3. ALWAYS prefer matching an existing topic - use the EXACT name and include existingTopicId
4. Only suggest a new topic if nothing in existing list is appropriate
5. Topic names: Title Case (e.g., "Bondage" not "bondage")
6. Keep topics broad enough to be reusable across multiple packs
</instructions>
Return a JSON object with:
- topics: Array of objects with:
- name: The topic name (use exact existing name if matching)
- isNew: false if matching an existing topic, true if suggesting a new one
- existingTopicId: The ID of the existing topic if isNew is false (from the EXISTING TOPICS WITH IDs list)
- reasoning: Brief explanation of why these topics were chosen`;
<output_format>
{
"topics": [
{
"name": string, // Topic name (use exact existing name if matching)
"isNew": boolean, // false if matching existing, true if new
"existingTopicId": string|null // ID from existing topics if isNew is false
}
],
"reasoning": string // Brief explanation of why these topics were chosen
}
</output_format>`;
const response = await openai.chat.completions.create({
model: getModel('fix'),
@@ -59,7 +72,7 @@ Return a JSON object with:
{ role: 'user', content: prompt },
],
response_format: { type: 'json_object' },
temperature: 0.3,
temperature: getTemperature('fix', 0.3),
});
const content = response.choices[0].message.content;
+40 -6
View File
@@ -12,6 +12,7 @@ export type ModelPurpose = 'generate' | 'fix' | 'polish' | 'describe_image';
export interface CouncilGenerator {
model: string;
temperature?: number;
}
export type CouncilSelectionMode = 'whole_set' | 'cherry_pick';
@@ -20,6 +21,7 @@ export interface CouncilConfig {
enabled: boolean;
generators: CouncilGenerator[];
reviewerModel: string;
reviewerTemperature?: number;
selectionMode: CouncilSelectionMode;
cherryPickEnsureIntensityDistribution: boolean;
}
@@ -41,6 +43,9 @@ export interface GeneratedQuestion {
requires_props?: string[] | null;
location_type?: 'home' | 'public' | 'outdoors' | 'travel' | 'anywhere';
effort_level?: 'spontaneous' | 'low' | 'medium' | 'planned';
// Inverse pair tracking - questions with the same inverse_pair_id are inverses of each other
// First question in pair (lower array index) is primary, second gets inverse_of set
inverse_pair_id?: string | null;
// Cherry-pick provenance (only set in cherry-pick mode)
sourceGeneratorIndex?: number;
sourceGeneratorModel?: string;
@@ -62,16 +67,29 @@ export interface GeneratedPackIdea {
// REVIEW TYPES
// =============================================================================
export interface QuestionReviewScores {
guidelineCompliance: number;
creativity: number;
clarity: number;
intensityAccuracy: number;
anatomicalConsistency: number;
partnerTextQuality: number;
coupleTargeting: number;
initiatorTargeting: number;
}
export interface QuestionReview {
index: number;
verdict: 'pass' | 'flag' | 'reject';
issues: string[];
suggestions?: string;
scores: {
guidelineCompliance: number;
creativity: number;
clarity: number;
intensityAccuracy: number;
scores: QuestionReviewScores;
// Additional detailed feedback
intensitySuggestion?: number | null; // Suggested intensity if current seems wrong
targetingSuggestions?: {
suggestedCoupleTargets?: string[] | null;
suggestedInitiator?: string[] | null;
reason?: string;
};
}
@@ -188,6 +206,22 @@ export interface TextAnalysis {
suggested_text: string;
suggested_partner_text: string | null;
reason: string;
// Intensity suggestion (only if AI thinks current is wrong)
suggested_intensity?: number | null;
intensity_reason?: string | null;
}
export interface DeletionAnalysis {
id: string;
category: 'duplicate' | 'redundant' | 'off-tone' | 'unsafe' | 'too-vague' | 'broken' | 'off-topic';
reason: string;
duplicate_of_id?: string | null;
}
export interface PropsAnalysis {
id: string;
suggested_required_props: string[] | null;
reason: string;
}
export interface ExtractedTopic {
@@ -216,4 +250,4 @@ export interface SelectionResult {
// TONE LEVEL TYPE
// =============================================================================
export type ToneLevel = 0 | 1 | 2 | 3 | 4 | 5;
export type ToneLevel = 1 | 2 | 3 | 4 | 5;
+131 -3
View File
@@ -66,17 +66,23 @@ export function AiSettingsPage() {
setFormData({
openrouter_api_key: config.openrouter_api_key || '',
default_model: config.default_model || '',
default_temperature: config.default_temperature,
model_generate: config.model_generate || '',
temperature_generate: config.temperature_generate,
model_fix: config.model_fix || '',
temperature_fix: config.temperature_fix,
model_polish: config.model_polish || '',
temperature_polish: config.temperature_polish,
council_enabled: config.council_enabled || false,
council_generator_model: config.council_generator_model || '',
council_generators: generators,
council_reviewer_model: config.council_reviewer_model || '',
council_reviewer_temperature: config.council_reviewer_temperature,
council_selection_mode: config.council_selection_mode || 'whole_set',
cherry_pick_ensure_intensity_distribution: config.cherry_pick_ensure_intensity_distribution ?? true,
classifier_enabled: config.classifier_enabled ?? true,
classifier_model: config.classifier_model || 'openai/gpt-4o',
classifier_temperature: config.classifier_temperature,
classifier_prompt: config.classifier_prompt || '',
heuristics_enabled: config.heuristics_enabled ?? false,
heuristic_min_text_length: config.heuristic_min_text_length ?? 12,
@@ -164,17 +170,23 @@ export function AiSettingsPage() {
setFormData({
openrouter_api_key: config.openrouter_api_key || '',
default_model: config.default_model || '',
default_temperature: config.default_temperature,
model_generate: config.model_generate || '',
temperature_generate: config.temperature_generate,
model_fix: config.model_fix || '',
temperature_fix: config.temperature_fix,
model_polish: config.model_polish || '',
temperature_polish: config.temperature_polish,
council_enabled: config.council_enabled || false,
council_generator_model: config.council_generator_model || '',
council_generators: generators,
council_reviewer_model: config.council_reviewer_model || '',
council_reviewer_temperature: config.council_reviewer_temperature,
council_selection_mode: config.council_selection_mode || 'whole_set',
cherry_pick_ensure_intensity_distribution: config.cherry_pick_ensure_intensity_distribution ?? true,
classifier_enabled: config.classifier_enabled ?? true,
classifier_model: config.classifier_model || 'openai/gpt-4o',
classifier_temperature: config.classifier_temperature,
classifier_prompt: config.classifier_prompt || '',
heuristics_enabled: config.heuristics_enabled ?? false,
heuristic_min_text_length: config.heuristic_min_text_length ?? 12,
@@ -208,8 +220,8 @@ export function AiSettingsPage() {
handleChange('council_generators', newGenerators);
};
const updateGeneratorModel = (index: number, model: string) => {
const newGenerators = generators.map((g, i) => i === index ? { ...g, model } : g);
const updateGenerator = (index: number, updates: Partial<CouncilGenerator>) => {
const newGenerators = generators.map((g, i) => i === index ? { ...g, ...updates } : g);
handleChange('council_generators', newGenerators);
};
@@ -367,6 +379,7 @@ export function AiSettingsPage() {
/>
</div>
<div className="grid grid-cols-[1fr,120px] gap-4">
<div className="space-y-2">
<Label htmlFor="classifier-model">Classifier Model</Label>
<ModelCombobox
@@ -378,6 +391,20 @@ export function AiSettingsPage() {
Model used for classification (must support vision if image moderation is needed)
</p>
</div>
<div className="space-y-2">
<Label htmlFor="classifier-temperature">Temp</Label>
<Input
id="classifier-temperature"
type="number"
step="0.1"
min="0"
max="2"
value={formData.classifier_temperature ?? ''}
onChange={(e) => handleChange('classifier_temperature', e.target.value === '' ? null : Number(e.target.value))}
placeholder="1.0"
/>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="classifier-prompt">System Prompt</Label>
@@ -624,6 +651,7 @@ export function AiSettingsPage() {
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
<div className="grid grid-cols-[1fr,120px] gap-4">
<div className="space-y-2">
<Label htmlFor="default-model">Default Model</Label>
<ModelCombobox
@@ -635,6 +663,20 @@ export function AiSettingsPage() {
Fallback model when no specific model is configured
</p>
</div>
<div className="space-y-2">
<Label htmlFor="default-temperature">Temp</Label>
<Input
id="default-temperature"
type="number"
step="0.1"
min="0"
max="2"
value={formData.default_temperature ?? ''}
onChange={(e) => handleChange('default_temperature', e.target.value === '' ? null : Number(e.target.value))}
placeholder="0.7"
/>
</div>
</div>
<Separator />
@@ -663,6 +705,7 @@ export function AiSettingsPage() {
{!formData.council_enabled ? (
/* Single generator mode */
<div className="grid grid-cols-[1fr,120px] gap-4">
<div className="space-y-2">
<Label htmlFor="model-generate">Generation Model</Label>
<ModelCombobox
@@ -674,6 +717,20 @@ export function AiSettingsPage() {
For questions, category ideas, and pack ideas
</p>
</div>
<div className="space-y-2">
<Label htmlFor="generate-temperature">Temp</Label>
<Input
id="generate-temperature"
type="number"
step="0.1"
min="0"
max="2"
value={formData.temperature_generate ?? ''}
onChange={(e) => handleChange('temperature_generate', e.target.value === '' ? null : Number(e.target.value))}
placeholder="0.9"
/>
</div>
</div>
) : (
/* Council mode - multiple generators + reviewer */
<div className="space-y-4 pl-4 border-l-2 border-primary/30">
@@ -698,10 +755,21 @@ export function AiSettingsPage() {
</div>
<ModelCombobox
value={gen.model}
onChange={(value) => updateGeneratorModel(index, value)}
onChange={(value) => updateGenerator(index, { model: value })}
placeholder={index === 0 ? "anthropic/claude-3.5-sonnet" : "Select model..."}
className="flex-1"
/>
<Input
type="number"
step="0.1"
min="0"
max="2"
value={gen.temperature ?? ''}
onChange={(e) => updateGenerator(index, { temperature: e.target.value === '' ? undefined : Number(e.target.value) })}
placeholder="0.9"
className="w-[80px]"
aria-label={`Generator ${index + 1} temperature`}
/>
<Button
type="button"
variant="ghost"
@@ -717,6 +785,7 @@ export function AiSettingsPage() {
</div>
{/* Reviewer */}
<div className="grid grid-cols-[1fr,120px] gap-4">
<div className="space-y-2">
<Label htmlFor="council-reviewer" className="text-sm">Reviewer Model</Label>
<ModelCombobox
@@ -728,6 +797,20 @@ export function AiSettingsPage() {
Reviews outputs and selects the best
</p>
</div>
<div className="space-y-2">
<Label htmlFor="reviewer-temperature" className="text-sm">Temp</Label>
<Input
id="reviewer-temperature"
type="number"
step="0.1"
min="0"
max="2"
value={formData.council_reviewer_temperature ?? ''}
onChange={(e) => handleChange('council_reviewer_temperature', e.target.value === '' ? null : Number(e.target.value))}
placeholder="0.3"
/>
</div>
</div>
{/* Selection Mode */}
<div className="space-y-2">
@@ -794,6 +877,7 @@ export function AiSettingsPage() {
{/* Ideas Model - shown when council mode is ON (since council only applies to questions) */}
{formData.council_enabled && (
<div className="grid grid-cols-[1fr,120px] gap-4">
<div className="space-y-2">
<Label htmlFor="model-generate">Ideas Model</Label>
<ModelCombobox
@@ -805,6 +889,20 @@ export function AiSettingsPage() {
For category ideas and pack ideas (council mode only applies to questions)
</p>
</div>
<div className="space-y-2">
<Label htmlFor="ideas-temperature">Temp</Label>
<Input
id="ideas-temperature"
type="number"
step="0.1"
min="0"
max="2"
value={formData.temperature_generate ?? ''}
onChange={(e) => handleChange('temperature_generate', e.target.value === '' ? null : Number(e.target.value))}
placeholder="0.9"
/>
</div>
</div>
)}
</div>
@@ -812,6 +910,7 @@ export function AiSettingsPage() {
{/* Other models - always visible */}
<div className="grid md:grid-cols-2 gap-4">
<div className="grid grid-cols-[1fr,100px] gap-2">
<div className="space-y-2">
<Label htmlFor="model-fix">Analysis Model</Label>
<ModelCombobox
@@ -823,7 +922,22 @@ export function AiSettingsPage() {
For analyzing & fixing content
</p>
</div>
<div className="space-y-2">
<Label htmlFor="fix-temperature">Temp</Label>
<Input
id="fix-temperature"
type="number"
step="0.1"
min="0"
max="2"
value={formData.temperature_fix ?? ''}
onChange={(e) => handleChange('temperature_fix', e.target.value === '' ? null : Number(e.target.value))}
placeholder="0.5"
/>
</div>
</div>
<div className="grid grid-cols-[1fr,100px] gap-2">
<div className="space-y-2">
<Label htmlFor="model-polish">Polish Model</Label>
<ModelCombobox
@@ -835,6 +949,20 @@ export function AiSettingsPage() {
For polishing & improving text
</p>
</div>
<div className="space-y-2">
<Label htmlFor="polish-temperature">Temp</Label>
<Input
id="polish-temperature"
type="number"
step="0.1"
min="0"
max="2"
value={formData.temperature_polish ?? ''}
onChange={(e) => handleChange('temperature_polish', e.target.value === '' ? null : Number(e.target.value))}
placeholder="0.7"
/>
</div>
</div>
</div>
</CardContent>
+220 -87
View File
@@ -3,11 +3,13 @@ import { Link } from 'react-router-dom';
import { supabase } from '@/config';
import { auditedSupabase } from '@/hooks/useAuditedSupabase';
import { useEntityForm } from '@/hooks/useEntityForm';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import { Switch } from '@/components/ui/switch';
import { IconPicker, IconPreview } from '@/components/ui/icon-picker';
import { Skeleton } from '@/components/ui/skeleton';
import { PaginationControls } from '@/components/ui/pagination';
@@ -20,10 +22,27 @@ import {
DialogTitle,
DialogTrigger,
} from '@/components/ui/dialog';
import { Plus, Package, Pencil, Trash2, Sparkles, Loader2, ChevronUp, ChevronDown } from 'lucide-react';
import { Plus, Package, Pencil, Trash2, Sparkles, Loader2, GripVertical } from 'lucide-react';
import { toast } from 'sonner';
import { AiGeneratorDialog } from '@/components/ai/AiGeneratorDialog';
import { AIPolishButton } from '@/components/ai/AIPolishButton';
import {
DndContext,
closestCenter,
KeyboardSensor,
PointerSensor,
useSensor,
useSensors,
DragEndEvent,
} from '@dnd-kit/core';
import {
arrayMove,
SortableContext,
sortableKeyboardCoordinates,
useSortable,
rectSortingStrategy,
} from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
// =============================================================================
// Types
@@ -36,6 +55,7 @@ interface Category {
icon: string | null;
sort_order: number | null;
created_at: string | null;
is_public: boolean;
pack_count?: number;
}
@@ -43,6 +63,109 @@ interface CategoryFormData {
name: string;
description: string;
icon: string;
is_public: boolean;
}
// =============================================================================
// Sortable Card Component
// =============================================================================
interface SortableCategoryCardProps {
category: Category;
onEdit: (category: Category) => void;
onDelete: (category: Category) => void;
onToggleVisibility: (category: Category) => void;
}
function SortableCategoryCard({ category, onEdit, onDelete, onToggleVisibility }: SortableCategoryCardProps) {
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({ id: category.id });
const style = {
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0.5 : 1,
zIndex: isDragging ? 1000 : 'auto',
};
return (
<Card
ref={setNodeRef}
style={style}
className="group flex flex-col hover:border-primary/50 transition-colors"
>
<CardHeader className="flex-row items-start justify-between space-y-0 pb-2">
<div className="flex items-center gap-2">
<button
{...attributes}
{...listeners}
className="cursor-grab active:cursor-grabbing p-1 -ml-1 rounded hover:bg-muted opacity-0 group-hover:opacity-100 transition-opacity touch-none"
aria-label="Drag to reorder"
>
<GripVertical className="h-4 w-4 text-muted-foreground" />
</button>
<div className="rounded-lg bg-primary/10 p-2.5">
<IconPreview value={category.icon} className="h-6 w-6 text-primary" />
</div>
</div>
<div className="flex items-center gap-2">
<span className="text-[10px] uppercase tracking-wider text-muted-foreground font-medium">
{category.is_public ? 'Public' : 'Hidden'}
</span>
<Switch
checked={category.is_public}
onCheckedChange={() => onToggleVisibility(category)}
className="scale-75 origin-right"
/>
</div>
</CardHeader>
<CardContent className="flex-1 flex flex-col gap-4">
<div className="space-y-1">
<CardTitle className="text-lg">{category.name}</CardTitle>
<CardDescription className="line-clamp-2 h-10">
{category.description || 'No description'}
</CardDescription>
</div>
<div className="flex items-center justify-between mt-auto">
<Badge variant="secondary" className="font-normal">
{category.pack_count} pack{category.pack_count !== 1 ? 's' : ''}
</Badge>
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-muted-foreground hover:text-foreground"
onClick={() => onEdit(category)}
>
<Pencil className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-muted-foreground hover:text-destructive"
onClick={() => onDelete(category)}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</div>
<Link to={`/categories/${category.id}/packs`}>
<Button variant="outline" className="w-full">
View Packs
</Button>
</Link>
</CardContent>
</Card>
);
}
// =============================================================================
@@ -60,11 +183,12 @@ export function CategoriesPage() {
// Form state using the new hook
const form = useEntityForm<CategoryFormData, Category>(
{ name: '', description: '', icon: 'bookmark-outline' },
{ name: '', description: '', icon: 'bookmark-outline', is_public: true },
(category) => ({
name: category.name,
description: category.description || '',
icon: category.icon || 'bookmark-outline',
is_public: category.is_public ?? true,
})
);
@@ -139,6 +263,7 @@ export function CategoriesPage() {
name: idea.name,
description: idea.description || '',
icon: idea.icon || 'bookmark-outline',
is_public: true,
});
};
@@ -154,6 +279,7 @@ export function CategoriesPage() {
name: form.formData.name,
description: form.formData.description || null,
icon: form.formData.icon || null,
is_public: form.formData.is_public,
};
if (form.editingItem) {
@@ -195,31 +321,70 @@ export function CategoriesPage() {
}
};
const handleMove = async (category: Category, direction: 'up' | 'down') => {
const currentIndex = categories.findIndex(c => c.id === category.id);
const targetIndex = direction === 'up' ? currentIndex - 1 : currentIndex + 1;
if (targetIndex < 0 || targetIndex >= categories.length) return;
const targetCategory = categories[targetIndex];
const handleToggleVisibility = async (category: Category) => {
try {
const currentOrder = category.sort_order ?? currentIndex;
const targetOrder = targetCategory.sort_order ?? targetIndex;
await Promise.all([
auditedSupabase.update('categories', category.id, { sort_order: targetOrder }),
auditedSupabase.update('categories', targetCategory.id, { sort_order: currentOrder }),
]);
// Optimistic update
const newCategories = [...categories];
[newCategories[currentIndex], newCategories[targetIndex]] = [newCategories[targetIndex], newCategories[currentIndex]];
setCategories(newCategories);
setCategories(categories.map(c =>
c.id === category.id ? { ...c, is_public: !c.is_public } : c
));
const { error } = await auditedSupabase.update('categories', category.id, {
is_public: !category.is_public
});
if (error) throw error;
toast.success(category.is_public ? 'Category hidden' : 'Category made public');
} catch (error) {
toast.error('Failed to reorder categories');
toast.error('Failed to update visibility');
console.error(error);
fetchCategories();
fetchCategories(); // Revert
}
};
// =============================================================================
// Drag and Drop
// =============================================================================
const sensors = useSensors(
useSensor(PointerSensor, {
activationConstraint: {
distance: 8,
},
}),
useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates,
})
);
const handleDragEnd = async (event: DragEndEvent) => {
const { active, over } = event;
if (!over || active.id === over.id) return;
const oldIndex = categories.findIndex(c => c.id === active.id);
const newIndex = categories.findIndex(c => c.id === over.id);
// Optimistic UI update
const newCategories = arrayMove(categories, oldIndex, newIndex);
setCategories(newCategories);
// Persist new order to database
try {
const updates = newCategories.map((category, index) =>
auditedSupabase.update('categories', category.id, { sort_order: index })
);
const results = await Promise.all(updates);
const hasError = results.some(r => r.error);
if (hasError) {
throw new Error('Failed to update some categories');
}
} catch (error) {
toast.error('Failed to save new order');
console.error(error);
fetchCategories(); // Revert on error
}
};
@@ -321,6 +486,19 @@ export function CategoriesPage() {
rows={3}
/>
</div>
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label>Public</Label>
<p className="text-sm text-muted-foreground">
Visible to users in the app
</p>
</div>
<Switch
checked={form.formData.is_public}
onCheckedChange={(checked) => form.setField('is_public', checked)}
/>
</div>
</div>
<DialogFooter>
@@ -357,73 +535,28 @@ export function CategoriesPage() {
</Button>
</Card>
) : (
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={handleDragEnd}
>
<SortableContext
items={categories.map(c => c.id)}
strategy={rectSortingStrategy}
>
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{categories.map((category, index) => (
<Card key={category.id} className="group hover:shadow-md transition-shadow">
<CardHeader className="pb-3">
<div className="flex items-start justify-between">
<div className="flex items-center gap-2">
<div className="flex flex-col opacity-0 group-hover:opacity-100 transition-opacity">
<Button
variant="ghost"
size="icon"
className="h-6 w-6"
onClick={() => handleMove(category, 'up')}
disabled={index === 0}
>
<ChevronUp className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-6 w-6"
onClick={() => handleMove(category, 'down')}
disabled={index === categories.length - 1}
>
<ChevronDown className="h-4 w-4" />
</Button>
</div>
<div className="text-4xl mb-2">
<IconPreview value={category.icon} fallback="bookmark-outline" className="text-4xl" />
</div>
</div>
<div className="opacity-0 group-hover:opacity-100 transition-opacity flex gap-1">
<Button
variant="ghost"
size="icon"
onClick={() => form.openEdit(category)}
>
<Pencil className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
onClick={() => handleDelete(category)}
>
<Trash2 className="h-4 w-4 text-destructive" />
</Button>
</div>
</div>
<CardTitle>{category.name}</CardTitle>
<CardDescription className="line-clamp-2">
{category.description || 'No description'}
</CardDescription>
</CardHeader>
<CardContent>
<div className="flex items-center justify-between">
<span className="text-sm text-muted-foreground">
{category.pack_count} pack{category.pack_count !== 1 ? 's' : ''}
</span>
<Link to={`/categories/${category.id}/packs`}>
<Button variant="outline" size="sm">
View Packs
</Button>
</Link>
</div>
</CardContent>
</Card>
{categories.map((category) => (
<SortableCategoryCard
key={category.id}
category={category}
onEdit={form.openEdit}
onDelete={handleDelete}
onToggleVisibility={handleToggleVisibility}
/>
))}
</div>
</SortableContext>
</DndContext>
)}
{categories.length > 0 && (
@@ -249,14 +249,19 @@ export function DarePacksPage() {
const currentOrder = pack.sort_order ?? currentIndex;
const targetOrder = targetPack.sort_order ?? targetIndex;
await Promise.all([
const [currentUpdate, targetUpdate] = await Promise.all([
auditedSupabase.update('dare_packs', pack.id, { sort_order: targetOrder }),
auditedSupabase.update('dare_packs', targetPack.id, { sort_order: currentOrder }),
]);
if (currentUpdate.error || targetUpdate.error) {
throw currentUpdate.error ?? targetUpdate.error ?? new Error('Failed to update dare pack order');
}
// Optimistic update
const newPacks = [...darePacks];
[newPacks[currentIndex], newPacks[targetIndex]] = [newPacks[targetIndex], newPacks[currentIndex]];
newPacks[currentIndex] = { ...targetPack, sort_order: currentOrder };
newPacks[targetIndex] = { ...pack, sort_order: targetOrder };
setDarePacks(newPacks);
} catch (error) {
toast.error('Failed to reorder dare packs');
+21 -4
View File
@@ -22,7 +22,7 @@ import { IconPreview } from '@/components/ui/icon-picker';
interface Category {
id: string;
name: string;
icon: string | null;
icon?: string | null;
}
interface Topic {
@@ -54,6 +54,7 @@ export function PacksPage() {
// Data state
const [category, setCategory] = useState<Category | null>(null);
const [categories, setCategories] = useState<Category[]>([]);
const [packs, setPacks] = useState<QuestionPack[]>([]);
const [loading, setLoading] = useState(true);
const [aiDialogOpen, setAiDialogOpen] = useState(false);
@@ -74,6 +75,7 @@ export function PacksPage() {
is_premium: false,
is_public: false,
is_explicit: false,
category_id: categoryId ?? '',
},
(pack) => ({
name: pack.name,
@@ -82,6 +84,7 @@ export function PacksPage() {
is_premium: pack.is_premium,
is_public: pack.is_public,
is_explicit: pack.is_explicit,
category_id: pack.category_id || '',
})
);
@@ -102,6 +105,12 @@ export function PacksPage() {
setCategory(cat);
}
const { data: categoriesData } = await supabase
.from('categories')
.select('id, name')
.order('name');
setCategories(categoriesData || []);
const from = (page - 1) * pageSize;
const to = from + pageSize - 1;
@@ -221,6 +230,7 @@ export function PacksPage() {
is_premium: form.formData.is_premium,
is_public: form.formData.is_public,
is_explicit: form.formData.is_explicit,
category_id: form.formData.category_id || null,
});
if (error) throw error;
packId = form.editingItem.id;
@@ -234,7 +244,7 @@ export function PacksPage() {
is_premium: form.formData.is_premium,
is_public: form.formData.is_public,
is_explicit: form.formData.is_explicit,
category_id: categoryId || null,
category_id: form.formData.category_id || null,
sort_order: totalCount,
})
.select('id')
@@ -294,6 +304,7 @@ export function PacksPage() {
is_premium: false,
is_public: false,
is_explicit: false,
category_id: categoryId ?? '',
});
setAiDialogOpen(false);
setSelectedTopicIds(new Set());
@@ -312,14 +323,19 @@ export function PacksPage() {
const currentOrder = pack.sort_order ?? currentIndex;
const targetOrder = targetPack.sort_order ?? targetIndex;
await Promise.all([
const [currentUpdate, targetUpdate] = await Promise.all([
auditedSupabase.update('question_packs', pack.id, { sort_order: targetOrder }),
auditedSupabase.update('question_packs', targetPack.id, { sort_order: currentOrder }),
]);
if (currentUpdate.error || targetUpdate.error) {
throw currentUpdate.error ?? targetUpdate.error ?? new Error('Failed to update pack order');
}
// Optimistic update
const newPacks = [...packs];
[newPacks[currentIndex], newPacks[targetIndex]] = [newPacks[targetIndex], newPacks[currentIndex]];
newPacks[currentIndex] = { ...targetPack, sort_order: currentOrder };
newPacks[targetIndex] = { ...pack, sort_order: targetOrder };
setPacks(newPacks);
} catch (error) {
toast.error('Failed to reorder packs');
@@ -391,6 +407,7 @@ export function PacksPage() {
onSave={handleSave}
saving={form.saving}
isEditing={form.isEditing}
categories={categories}
allTopics={allTopics}
selectedTopicIds={selectedTopicIds}
onTopicsChange={setSelectedTopicIds}
@@ -0,0 +1,865 @@
import { useEffect, useState } from 'react';
import { supabase } from '@/config';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Skeleton } from '@/components/ui/skeleton';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Bar, BarChart, ResponsiveContainer, XAxis, YAxis, Tooltip, Cell, Pie, PieChart as RechartsPieChart } from 'recharts';
import { Crown, MessageCircle, PieChart, Sparkles, Tag, Eye, EyeOff } from 'lucide-react';
interface QuestionRow {
id: string;
pack_id: string;
intensity: number | null;
partner_text: string | null;
inverse_of?: string | null;
}
interface PackRow {
id: string;
name: string;
is_premium: boolean;
is_explicit: boolean;
is_public: boolean;
category_id: string | null;
category?: { name: string } | { name: string }[] | null;
}
interface TopicRow {
id: string;
name: string;
}
interface PackTopicRow {
pack_id: string;
topic_id: string;
topics: TopicRow | TopicRow[] | null;
}
interface BreakdownItem {
id: string;
name: string;
count: number;
}
interface PackBreakdownItem {
id: string;
name: string;
count: number;
isPremium: boolean;
category: string;
}
interface ChartDatum {
name: string;
value: number;
color: string;
[key: string]: string | number;
}
type SegmentKey = 'all' | 'premium' | 'free';
const intensityPalette = ['#22c55e', '#84cc16', '#facc15', '#f97316', '#ef4444'];
const intensityLabels = ['Gentle', 'Warm', 'Playful', 'Steamy', 'Intense'];
const formatPercent = (count: number, total: number) => (total > 0 ? Math.round((count / total) * 100) : 0);
const getCategoryName = (category?: PackRow['category']) => {
if (!category) return 'Uncategorized';
if (Array.isArray(category)) {
return category[0]?.name || 'Uncategorized';
}
return category.name || 'Uncategorized';
};
const normalizeTopics = (topics?: PackTopicRow['topics']) => {
if (!topics) return [] as TopicRow[];
return Array.isArray(topics) ? topics : [topics];
};
type PublishFilter = 'all' | 'published' | 'unpublished';
export function QuestionAnalyticsPage() {
const [loading, setLoading] = useState(true);
const [publishFilter, setPublishFilter] = useState<PublishFilter>('published');
const [stats, setStats] = useState({
totalQuestions: 0,
premiumQuestions: 0,
freeQuestions: 0,
taggedQuestions: 0,
});
const [premiumBreakdown, setPremiumBreakdown] = useState<ChartDatum[]>([]);
const [intensityBreakdown, setIntensityBreakdown] = useState<Record<SegmentKey, ChartDatum[]>>({
all: [],
premium: [],
free: [],
});
const [topicBreakdown, setTopicBreakdown] = useState<Record<SegmentKey, BreakdownItem[]>>({
all: [],
premium: [],
free: [],
});
const [categoryBreakdown, setCategoryBreakdown] = useState<Record<SegmentKey, BreakdownItem[]>>({
all: [],
premium: [],
free: [],
});
const [packBreakdown, setPackBreakdown] = useState<Record<SegmentKey, PackBreakdownItem[]>>({
all: [],
premium: [],
free: [],
});
const [intensitySegment, setIntensitySegment] = useState<SegmentKey>('all');
const [tagSegment, setTagSegment] = useState<SegmentKey>('all');
const [categorySegment, setCategorySegment] = useState<SegmentKey>('all');
const [packSegment, setPackSegment] = useState<SegmentKey>('all');
useEffect(() => {
const fetchAllQuestions = async () => {
const pageSize = 1000;
let page = 0;
const allQuestions: QuestionRow[] = [];
while (true) {
const from = page * pageSize;
const to = from + pageSize - 1;
const { data, error } = await supabase
.from('questions')
.select('id, pack_id, intensity, partner_text, inverse_of')
.range(from, to);
if (error) throw error;
const batch = data || [];
allQuestions.push(...batch);
if (batch.length < pageSize) {
break;
}
page += 1;
}
return allQuestions;
};
const fetchAnalytics = async () => {
setLoading(true);
try {
// Build pack query with optional is_public filter
let packQuery = supabase
.from('question_packs')
.select('id, name, is_premium, is_explicit, is_public, category_id, category:categories(name)');
if (publishFilter === 'published') {
packQuery = packQuery.eq('is_public', true);
} else if (publishFilter === 'unpublished') {
packQuery = packQuery.eq('is_public', false);
}
const [packsResult, topicsResult, packTopicsResult, questions] = await Promise.all([
packQuery,
supabase
.from('topics')
.select('id, name')
.order('name'),
supabase
.from('pack_topics')
.select('pack_id, topic_id, topics(id, name)'),
fetchAllQuestions(),
]);
if (packsResult.error) throw packsResult.error;
if (topicsResult.error) throw topicsResult.error;
if (packTopicsResult.error) throw packTopicsResult.error;
const packs = (packsResult.data || []) as PackRow[];
const topics = (topicsResult.data || []) as TopicRow[];
const packTopics = (packTopicsResult.data || []) as PackTopicRow[];
const packById = new Map<string, PackRow>();
packs.forEach((pack) => {
packById.set(pack.id, pack);
});
const seedTopicCounts = () => new Map<string, BreakdownItem>(
topics.map((topic) => [topic.id, { id: topic.id, name: topic.name, count: 0 }])
);
const topicCountsBySegment: Record<SegmentKey, Map<string, BreakdownItem>> = {
all: seedTopicCounts(),
premium: seedTopicCounts(),
free: seedTopicCounts(),
};
const packTopicsMap = new Map<string, TopicRow[]>();
packTopics.forEach((relation) => {
const topicsForPack = packTopicsMap.get(relation.pack_id) || [];
const topicsList = normalizeTopics(relation.topics);
if (topicsList.length === 0) return;
topicsForPack.push(...topicsList);
packTopicsMap.set(relation.pack_id, topicsForPack);
});
const emptyIntensityCounts = { 1: 0, 2: 0, 3: 0, 4: 0, 5: 0 };
const intensityCounts: Record<SegmentKey, Record<number, number>> = {
all: { ...emptyIntensityCounts },
premium: { ...emptyIntensityCounts },
free: { ...emptyIntensityCounts },
};
const untaggedCounts: Record<SegmentKey, number> = { all: 0, premium: 0, free: 0 };
const categoryCountsBySegment: Record<SegmentKey, Map<string, BreakdownItem>> = {
all: new Map(),
premium: new Map(),
free: new Map(),
};
const packCountsBySegment: Record<SegmentKey, Map<string, PackBreakdownItem>> = {
all: new Map(),
premium: new Map(),
free: new Map(),
};
let premiumCount = 0;
let freeCount = 0;
let taggedCount = 0;
const primaryQuestions = questions.filter((question) => question.inverse_of == null);
primaryQuestions.forEach((question) => {
const pack = packById.get(question.pack_id);
const isPremium = pack?.is_premium ?? false;
const segmentKey: SegmentKey = isPremium ? 'premium' : 'free';
const categoryName = getCategoryName(pack?.category);
const categoryId = pack?.category_id || 'uncategorized';
if (isPremium) {
premiumCount += 1;
} else {
freeCount += 1;
}
const intensity = question.intensity ?? 1;
intensityCounts.all[intensity] = (intensityCounts.all[intensity] || 0) + 1;
intensityCounts[segmentKey][intensity] = (intensityCounts[segmentKey][intensity] || 0) + 1;
const updatePackCounts = (key: SegmentKey) => {
const map = packCountsBySegment[key];
const packEntry = map.get(question.pack_id) || {
id: question.pack_id,
name: pack?.name || 'Unknown Pack',
count: 0,
isPremium: isPremium,
category: categoryName,
};
packEntry.count += 1;
map.set(question.pack_id, packEntry);
};
updatePackCounts('all');
updatePackCounts(segmentKey);
const updateCategoryCounts = (key: SegmentKey) => {
const map = categoryCountsBySegment[key];
const categoryEntry = map.get(categoryId) || {
id: categoryId,
name: categoryName,
count: 0,
};
categoryEntry.count += 1;
map.set(categoryId, categoryEntry);
};
updateCategoryCounts('all');
updateCategoryCounts(segmentKey);
const topicsForPack = packTopicsMap.get(question.pack_id) || [];
if (topicsForPack.length > 0) {
taggedCount += 1;
topicsForPack.forEach((topic) => {
const updateCounts = (map: Map<string, BreakdownItem>) => {
const topicEntry = map.get(topic.id) || {
id: topic.id,
name: topic.name,
count: 0,
};
topicEntry.count += 1;
map.set(topic.id, topicEntry);
};
updateCounts(topicCountsBySegment.all);
updateCounts(topicCountsBySegment[segmentKey]);
});
} else {
untaggedCounts.all += 1;
untaggedCounts[segmentKey] += 1;
}
});
const totalQuestions = primaryQuestions.length;
const premiumData: ChartDatum[] = [
{ name: 'Premium', value: premiumCount, color: '#f59e0b' },
{ name: 'Free', value: freeCount, color: '#22c55e' },
];
const buildIntensityData = (key: SegmentKey) => intensityLabels.map((label, index) => ({
name: label,
value: intensityCounts[key][index + 1] || 0,
color: intensityPalette[index],
}));
const buildTopicData = (key: SegmentKey) => {
const data = Array.from(topicCountsBySegment[key].values());
if (untaggedCounts[key] > 0 || data.length === 0) {
data.push({ id: 'untagged', name: 'Untagged', count: untaggedCounts[key] });
}
data.sort((a, b) => b.count - a.count);
return data;
};
const intensityData = {
all: buildIntensityData('all'),
premium: buildIntensityData('premium'),
free: buildIntensityData('free'),
};
const topicData = {
all: buildTopicData('all'),
premium: buildTopicData('premium'),
free: buildTopicData('free'),
};
const buildCategoryData = (key: SegmentKey) =>
Array.from(categoryCountsBySegment[key].values()).sort((a, b) => b.count - a.count);
const buildPackData = (key: SegmentKey) =>
Array.from(packCountsBySegment[key].values()).sort((a, b) => b.count - a.count);
const categoryData = {
all: buildCategoryData('all'),
premium: buildCategoryData('premium'),
free: buildCategoryData('free'),
};
const packData = {
all: buildPackData('all'),
premium: buildPackData('premium'),
free: buildPackData('free'),
};
setStats({
totalQuestions,
premiumQuestions: premiumCount,
freeQuestions: freeCount,
taggedQuestions: taggedCount,
});
setPremiumBreakdown(premiumData);
setIntensityBreakdown(intensityData);
setTopicBreakdown(topicData);
setCategoryBreakdown(categoryData);
setPackBreakdown(packData);
} catch (error) {
console.error('Failed to fetch question analytics:', error);
} finally {
setLoading(false);
}
};
fetchAnalytics();
}, [publishFilter]);
const premiumPercent = formatPercent(stats.premiumQuestions, stats.totalQuestions);
const freePercent = formatPercent(stats.freeQuestions, stats.totalQuestions);
const taggedPercent = formatPercent(stats.taggedQuestions, stats.totalQuestions);
const segmentOptions: { key: SegmentKey; label: string }[] = [
{ key: 'all', label: 'All' },
{ key: 'premium', label: 'Premium' },
{ key: 'free', label: 'Free' },
];
const segmentLabelByKey: Record<SegmentKey, string> = {
all: 'All',
premium: 'Premium',
free: 'Free',
};
const totalBySegment: Record<SegmentKey, number> = {
all: stats.totalQuestions,
premium: stats.premiumQuestions,
free: stats.freeQuestions,
};
const segmentShareByKey: Record<SegmentKey, string> = {
all: '100',
premium: stats.totalQuestions ? ((stats.premiumQuestions / stats.totalQuestions) * 100).toFixed(0) : '0',
free: stats.totalQuestions ? ((stats.freeQuestions / stats.totalQuestions) * 100).toFixed(0) : '0',
};
const intensityData = intensityBreakdown[intensitySegment];
const tagData = topicBreakdown[tagSegment];
const categoryData = categoryBreakdown[categorySegment];
const packData = packBreakdown[packSegment];
const publishFilterOptions: { key: PublishFilter; label: string; icon: typeof Eye }[] = [
{ key: 'all', label: 'All Packs', icon: Eye },
{ key: 'published', label: 'Published Only', icon: Eye },
{ key: 'unpublished', label: 'Unpublished Only', icon: EyeOff },
];
return (
<div className="space-y-6">
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<div>
<h1 className="text-3xl font-bold tracking-tight flex items-center gap-2">
<PieChart className="h-8 w-8 text-primary" />
Question Analytics
</h1>
<p className="text-muted-foreground">
Deep dive into premium coverage, tags, categories, and intensity distribution
</p>
</div>
<div className="flex flex-col gap-2">
<p className="text-sm text-muted-foreground">Scope</p>
<div className="flex items-center gap-2">
{publishFilterOptions.map((option) => {
const Icon = option.icon;
return (
<Button
key={option.key}
variant={publishFilter === option.key ? 'default' : 'outline'}
size="sm"
onClick={() => setPublishFilter(option.key)}
className="gap-2"
>
<Icon className="h-4 w-4" />
{option.label}
</Button>
);
})}
</div>
</div>
</div>
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Total Questions</CardTitle>
<MessageCircle className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
{loading ? (
<Skeleton className="h-8 w-20" />
) : (
<div className="text-2xl font-bold">
{stats.totalQuestions.toLocaleString()}
</div>
)}
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Premium Questions</CardTitle>
<Crown className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
{loading ? (
<Skeleton className="h-8 w-20" />
) : (
<>
<div className="text-2xl font-bold">
{stats.premiumQuestions.toLocaleString()}
</div>
<p className="text-xs text-muted-foreground">
{premiumPercent}% of all questions
</p>
</>
)}
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Free Questions</CardTitle>
<Sparkles className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
{loading ? (
<Skeleton className="h-8 w-20" />
) : (
<>
<div className="text-2xl font-bold">
{stats.freeQuestions.toLocaleString()}
</div>
<p className="text-xs text-muted-foreground">
{freePercent}% of all questions
</p>
</>
)}
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Tagged Questions</CardTitle>
<Tag className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
{loading ? (
<Skeleton className="h-8 w-20" />
) : (
<>
<div className="text-2xl font-bold">
{stats.taggedQuestions.toLocaleString()}
</div>
<p className="text-xs text-muted-foreground">
{taggedPercent}% of all questions
</p>
</>
)}
</CardContent>
</Card>
</div>
<div className="grid gap-6 lg:grid-cols-2">
<Card>
<CardHeader>
<CardTitle>Premium vs Free</CardTitle>
<CardDescription>
Question count split by pack premium status
</CardDescription>
</CardHeader>
<CardContent>
{loading ? (
<Skeleton className="h-[300px] w-full" />
) : stats.totalQuestions === 0 ? (
<div className="flex items-center justify-center h-[300px] text-muted-foreground">
No question data available yet
</div>
) : (
<div className="h-[300px] flex items-center justify-center">
<ResponsiveContainer width="100%" height="100%">
<RechartsPieChart>
<Pie
data={premiumBreakdown}
dataKey="value"
cx="50%"
cy="50%"
innerRadius={60}
outerRadius={100}
paddingAngle={2}
label={({ name, percent }) =>
`${name}: ${((percent ?? 0) * 100).toFixed(0)}%`
}
>
{premiumBreakdown.map((entry, index) => (
<Cell key={`cell-${index}`} fill={entry.color} />
))}
</Pie>
<Tooltip
contentStyle={{
borderRadius: '8px',
border: '1px solid #e2e8f0',
boxShadow: '0 4px 6px -1px rgb(0 0 0 / 0.1)',
}}
/>
</RechartsPieChart>
</ResponsiveContainer>
</div>
)}
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div>
<CardTitle>Intensity Distribution</CardTitle>
<CardDescription>
How questions are spread across intensity levels
</CardDescription>
</div>
<div className="flex flex-col items-start gap-1 sm:items-end">
<div className="flex items-center gap-2">
{segmentOptions.map((option) => (
<Button
key={option.key}
variant={intensitySegment === option.key ? 'default' : 'outline'}
size="sm"
onClick={() => setIntensitySegment(option.key)}
className="rounded-full"
>
{option.label}
</Button>
))}
</div>
<p className="text-xs text-muted-foreground">
{segmentLabelByKey[intensitySegment]}: {totalBySegment[intensitySegment].toLocaleString()} questions ({segmentShareByKey[intensitySegment]}%)
</p>
</div>
</CardHeader>
<CardContent>
{loading ? (
<Skeleton className="h-[300px] w-full" />
) : totalBySegment[intensitySegment] === 0 ? (
<div className="flex items-center justify-center h-[300px] text-muted-foreground">
No questions in this segment yet
</div>
) : (
<div className="h-[300px]">
<ResponsiveContainer width="100%" height="100%">
<BarChart data={intensityData}>
<XAxis
dataKey="name"
stroke="hsl(var(--muted-foreground))"
fontSize={11}
tickLine={false}
axisLine={false}
/>
<YAxis
stroke="hsl(var(--muted-foreground))"
fontSize={11}
tickLine={false}
axisLine={false}
tickFormatter={(value) => `${value}`}
/>
<Tooltip
cursor={{ fill: 'rgba(255,255,255,0.03)' }}
contentStyle={{
borderRadius: '8px',
border: '1px solid #e2e8f0',
boxShadow: '0 4px 6px -1px rgb(0 0 0 / 0.1)',
}}
/>
<Bar dataKey="value" radius={[6, 6, 0, 0]}>
{intensityData.map((entry, index) => (
<Cell key={`cell-${index}`} fill={entry.color} />
))}
</Bar>
</BarChart>
</ResponsiveContainer>
</div>
)}
</CardContent>
</Card>
</div>
<Card>
<CardHeader className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div>
<CardTitle>Tag Breakdown</CardTitle>
<CardDescription>
Tags are derived from pack topics; questions in multi-tag packs count toward each tag
</CardDescription>
</div>
<div className="flex flex-col items-start gap-1 sm:items-end">
<div className="flex items-center gap-2">
{segmentOptions.map((option) => (
<Button
key={option.key}
variant={tagSegment === option.key ? 'default' : 'outline'}
size="sm"
onClick={() => setTagSegment(option.key)}
className="rounded-full"
>
{option.label}
</Button>
))}
</div>
<p className="text-xs text-muted-foreground">
{segmentLabelByKey[tagSegment]}: {totalBySegment[tagSegment].toLocaleString()} questions ({segmentShareByKey[tagSegment]}%)
</p>
</div>
</CardHeader>
<CardContent>
{loading ? (
<div className="space-y-3">
{Array.from({ length: 6 }).map((_, index) => (
<Skeleton key={index} className="h-14 w-full" />
))}
</div>
) : totalBySegment[tagSegment] === 0 ? (
<div className="text-center py-8 text-muted-foreground">
No questions in this segment yet
</div>
) : tagData.length === 0 ? (
<div className="text-center py-8 text-muted-foreground">
No tag data available yet
</div>
) : (
<div className="space-y-4">
{tagData.map((item) => {
const percentage = formatPercent(item.count, totalBySegment[tagSegment]);
return (
<div
key={item.id}
className="flex items-center gap-4 rounded-lg border px-4 py-3"
>
<div className="flex-1">
<div className="flex items-center justify-between">
<p className="font-medium">{item.name}</p>
<span className="text-sm text-muted-foreground">
{item.count.toLocaleString()}
</span>
</div>
<div className="mt-2 h-2 rounded-full bg-muted overflow-hidden">
<div
className="h-full rounded-full bg-primary/70"
style={{ width: `${percentage}%` }}
/>
</div>
</div>
<div className="w-12 text-right text-sm text-muted-foreground">
{percentage}%
</div>
</div>
);
})}
</div>
)}
</CardContent>
</Card>
<div className="grid gap-6 lg:grid-cols-2">
<Card>
<CardHeader className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div>
<CardTitle>Category Breakdown</CardTitle>
<CardDescription>
Distribution of questions across pack categories
</CardDescription>
</div>
<div className="flex flex-col items-start gap-1 sm:items-end">
<div className="flex items-center gap-2">
{segmentOptions.map((option) => (
<Button
key={option.key}
variant={categorySegment === option.key ? 'default' : 'outline'}
size="sm"
onClick={() => setCategorySegment(option.key)}
className="rounded-full"
>
{option.label}
</Button>
))}
</div>
<p className="text-xs text-muted-foreground">
{segmentLabelByKey[categorySegment]}: {totalBySegment[categorySegment].toLocaleString()} questions ({segmentShareByKey[categorySegment]}%)
</p>
</div>
</CardHeader>
<CardContent>
{loading ? (
<div className="space-y-3">
{Array.from({ length: 5 }).map((_, index) => (
<Skeleton key={index} className="h-12 w-full" />
))}
</div>
) : totalBySegment[categorySegment] === 0 ? (
<div className="text-center py-8 text-muted-foreground">
No questions in this segment yet
</div>
) : categoryData.length === 0 ? (
<div className="text-center py-8 text-muted-foreground">
No category data available yet
</div>
) : (
<div className="space-y-3">
{categoryData.map((item) => {
const percentage = formatPercent(item.count, totalBySegment[categorySegment]);
return (
<div
key={item.id}
className="flex items-center justify-between rounded-lg border px-4 py-3"
>
<div>
<p className="font-medium">{item.name}</p>
<p className="text-xs text-muted-foreground">{percentage}% of questions</p>
</div>
<div className="text-right">
<p className="text-lg font-semibold">{item.count.toLocaleString()}</p>
<p className="text-xs text-muted-foreground">questions</p>
</div>
</div>
);
})}
</div>
)}
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div>
<CardTitle>Top Packs</CardTitle>
<CardDescription>
Packs with the most questions right now
</CardDescription>
</div>
<div className="flex flex-col items-start gap-1 sm:items-end">
<div className="flex items-center gap-2">
{segmentOptions.map((option) => (
<Button
key={option.key}
variant={packSegment === option.key ? 'default' : 'outline'}
size="sm"
onClick={() => setPackSegment(option.key)}
className="rounded-full"
>
{option.label}
</Button>
))}
</div>
<p className="text-xs text-muted-foreground">
{segmentLabelByKey[packSegment]}: {totalBySegment[packSegment].toLocaleString()} questions ({segmentShareByKey[packSegment]}%)
</p>
</div>
</CardHeader>
<CardContent>
{loading ? (
<div className="space-y-3">
{Array.from({ length: 5 }).map((_, index) => (
<Skeleton key={index} className="h-12 w-full" />
))}
</div>
) : totalBySegment[packSegment] === 0 ? (
<div className="text-center py-8 text-muted-foreground">
No questions in this segment yet
</div>
) : packData.length === 0 ? (
<div className="text-center py-8 text-muted-foreground">
No pack data available yet
</div>
) : (
<div className="space-y-3">
{packData.slice(0, 8).map((pack) => (
<div
key={pack.id}
className="flex items-center justify-between rounded-lg border px-4 py-3"
>
<div className="min-w-0">
<p className="font-medium truncate">{pack.name}</p>
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<span>{pack.category}</span>
{pack.isPremium && (
<Badge variant="secondary" className="text-[10px]">
Premium
</Badge>
)}
</div>
</div>
<div className="text-right">
<p className="text-lg font-semibold">{pack.count.toLocaleString()}</p>
<p className="text-xs text-muted-foreground">questions</p>
</div>
</div>
))}
</div>
)}
</CardContent>
</Card>
</div>
</div>
);
}
+315 -21
View File
@@ -1,4 +1,4 @@
import { useState, useEffect, useCallback } from 'react';
import { useState, useEffect, useCallback, useMemo } from 'react';
import { useParams } from 'react-router-dom';
import { supabase } from '@/config';
import { auditedSupabase } from '@/hooks/useAuditedSupabase';
@@ -6,6 +6,7 @@ import { useRealtimeSubscription } from '@/hooks/useRealtimeSubscription';
import { RealtimeStatusIndicator } from '@/components/RealtimeStatusIndicator';
import { Button } from '@/components/ui/button';
import { Label } from '@/components/ui/label';
import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
import { Badge } from '@/components/ui/badge';
import { Skeleton } from '@/components/ui/skeleton';
@@ -28,7 +29,14 @@ import {
DialogTrigger,
} from '@/components/ui/dialog';
import { Card } from '@/components/ui/card';
import { Plus, Pencil, Trash2, Sparkles, Loader2, Users } from 'lucide-react';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Plus, Pencil, Trash2, Sparkles, Loader2, Users, Link2, Eye } from 'lucide-react';
import { Checkbox } from '@/components/ui/checkbox';
import { toast } from 'sonner';
import { AiGeneratorDialog } from '@/components/ai/AiGeneratorDialog';
@@ -51,6 +59,8 @@ interface Question {
intensity: number;
allowed_couple_genders: string[] | null;
target_user_genders: string[] | null;
required_props?: string[] | null;
inverse_of?: string | null;
created_at: string | null;
}
@@ -67,10 +77,15 @@ export function QuestionsPage() {
const [dialogOpen, setDialogOpen] = useState(false);
const [aiDialogOpen, setAiDialogOpen] = useState(false);
const [reviewOpen, setReviewOpen] = useState(false);
const [reviewQuestions, setReviewQuestions] = useState<Question[]>([]);
const [reviewLoading, setReviewLoading] = useState(false);
const [editingQuestion, setEditingQuestion] = useState<Question | null>(null);
const [saving, setSaving] = useState(false);
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
const [bulkDeleting, setBulkDeleting] = useState(false);
const [peekOpen, setPeekOpen] = useState(false);
const [peekPrimary, setPeekPrimary] = useState<Question | null>(null);
const [peekInverse, setPeekInverse] = useState<Question | null>(null);
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(25);
const [totalCount, setTotalCount] = useState(0);
@@ -82,42 +97,64 @@ export function QuestionsPage() {
intensity: number;
allowed_couple_genders: string[];
target_user_genders: string[];
required_props: string;
inverse_of: string;
}>({
text: '',
partner_text: '',
intensity: 1,
allowed_couple_genders: [],
target_user_genders: [],
required_props: '',
inverse_of: '',
});
// All questions in pack for inverse_of dropdown (loaded once)
const [allPackQuestions, setAllPackQuestions] = useState<Question[]>([]);
const fetchData = useCallback(async () => {
if (!packId) return;
setLoading(true);
try {
// Fetch pack info
const { data: packData } = await supabase
const from = (page - 1) * pageSize;
const to = from + pageSize - 1;
const packPromise = supabase
.from('question_packs')
.select('id, name, description, icon, is_explicit')
.eq('id', packId)
.single();
setPack(packData);
const from = (page - 1) * pageSize;
const to = from + pageSize - 1;
// Fetch questions
const { data: questionData, error, count } = await supabase
const questionsPromise = supabase
.from('questions')
.select('*', { count: 'exact' })
.eq('pack_id', packId)
.order('created_at', { ascending: true })
.range(from, to);
const allPackPromise = supabase
.from('questions')
.select('id, text, inverse_of')
.eq('pack_id', packId)
.is('deleted_at', null)
.order('created_at', { ascending: true });
const [
{ data: packData, error: packError },
{ data: questionData, error, count },
{ data: allPackData, error: allPackError },
] = await Promise.all([packPromise, questionsPromise, allPackPromise]);
if (packError) throw packError;
if (error) throw error;
setTotalCount(count || 0);
setQuestions(questionData || []);
setPack(packData);
if (allPackError) {
toast.error('Failed to load inverse peek data');
console.error(allPackError);
} else {
setAllPackQuestions((allPackData as Question[]) || []);
}
} catch (error) {
toast.error('Failed to load questions');
console.error(error);
@@ -161,19 +198,46 @@ export function QuestionsPage() {
useEffect(() => {
setPage(1);
setSelectedIds(new Set());
setReviewQuestions([]);
setReviewOpen(false);
}, [packId]);
useEffect(() => {
setSelectedIds(new Set());
}, [page, pageSize]);
const openCreateDialog = () => {
// Fetch all questions in pack for inverse_of dropdown
const fetchAllPackQuestions = useCallback(async () => {
if (!packId) return;
const { data } = await supabase
.from('questions')
.select('id, text, inverse_of')
.eq('pack_id', packId)
.is('deleted_at', null)
.order('created_at', { ascending: true });
setAllPackQuestions((data as Question[]) || []);
}, [packId]);
const primaryQuestionById = useMemo(() => {
return new Map(allPackQuestions.map(question => [question.id, question]));
}, [allPackQuestions]);
const openCreateDialog = async () => {
setEditingQuestion(null);
setFormData({ text: '', partner_text: '', intensity: 1, allowed_couple_genders: [], target_user_genders: [] });
setFormData({
text: '',
partner_text: '',
intensity: 1,
allowed_couple_genders: [],
target_user_genders: [],
required_props: '',
inverse_of: '',
});
await fetchAllPackQuestions();
setDialogOpen(true);
};
const openEditDialog = (question: Question) => {
const openEditDialog = async (question: Question) => {
setEditingQuestion(question);
setFormData({
text: question.text,
@@ -181,7 +245,10 @@ export function QuestionsPage() {
intensity: question.intensity,
allowed_couple_genders: question.allowed_couple_genders || [],
target_user_genders: question.target_user_genders || [],
required_props: question.required_props?.join(', ') ?? '',
inverse_of: question.inverse_of || '',
});
await fetchAllPackQuestions();
setDialogOpen(true);
};
@@ -191,6 +258,12 @@ export function QuestionsPage() {
return;
}
const requiredProps = formData.required_props
.split(/[,;\n]/)
.map(value => value.trim())
.filter(Boolean);
const requiredPropsValue = requiredProps.length > 0 ? requiredProps : null;
setSaving(true);
try {
if (editingQuestion) {
@@ -200,6 +273,8 @@ export function QuestionsPage() {
intensity: formData.intensity,
allowed_couple_genders: formData.allowed_couple_genders.length > 0 ? formData.allowed_couple_genders : null,
target_user_genders: formData.target_user_genders.length > 0 ? formData.target_user_genders : null,
required_props: requiredPropsValue,
inverse_of: formData.inverse_of || null,
});
if (error) throw error;
@@ -212,6 +287,8 @@ export function QuestionsPage() {
intensity: formData.intensity,
allowed_couple_genders: formData.allowed_couple_genders.length > 0 ? formData.allowed_couple_genders : null,
target_user_genders: formData.target_user_genders.length > 0 ? formData.target_user_genders : null,
required_props: requiredPropsValue,
inverse_of: formData.inverse_of || null,
});
if (error) throw error;
@@ -245,21 +322,104 @@ export function QuestionsPage() {
}
};
const handleAiGenerated = (generatedQuestions: Array<{ text: string; partner_text?: string; intensity: number }>) => {
// Bulk insert AI-generated questions
const handleAiGenerated = (generatedQuestions: Array<{ text: string; partner_text?: string; intensity: number; inverse_pair_id?: string | null }>) => {
// Bulk insert AI-generated questions with inverse_of linking
const insertQuestions = async () => {
try {
const { error } = await auditedSupabase.insert(
// Group questions by inverse_pair_id
const pairMap = new Map<string, typeof generatedQuestions>();
const standaloneQuestions: typeof generatedQuestions = [];
for (const q of generatedQuestions) {
if (q.inverse_pair_id) {
const existing = pairMap.get(q.inverse_pair_id) || [];
existing.push(q);
pairMap.set(q.inverse_pair_id, existing);
} else {
standaloneQuestions.push(q);
}
}
// Insert standalone questions first (no inverse_of)
if (standaloneQuestions.length > 0) {
const { error: standaloneError } = await auditedSupabase.insert(
'questions',
generatedQuestions.map(q => ({
standaloneQuestions.map(q => ({
pack_id: packId,
text: q.text,
partner_text: q.partner_text || null,
intensity: q.intensity,
inverse_of: null,
}))
);
if (standaloneError) throw standaloneError;
}
// Insert paired questions: primary first, then inverse with inverse_of link
for (const [, pair] of pairMap) {
if (pair.length >= 2) {
// First question is primary (inverse_of = NULL)
const primary = pair[0];
const { data: primaryData, error: primaryError } = await auditedSupabase.insert(
'questions',
[{
pack_id: packId,
text: primary.text,
partner_text: primary.partner_text || null,
intensity: primary.intensity,
inverse_of: null,
}]
);
if (primaryError) throw primaryError;
// Second question is inverse (inverse_of = primary's id)
const primaryId = primaryData?.[0]?.id;
if (primaryId) {
const inverse = pair[1];
const { error: inverseError } = await auditedSupabase.insert(
'questions',
[{
pack_id: packId,
text: inverse.text,
partner_text: inverse.partner_text || null,
intensity: inverse.intensity,
inverse_of: primaryId,
}]
);
if (inverseError) throw inverseError;
}
// Handle any additional questions in the pair (shouldn't happen, but just in case)
for (let i = 2; i < pair.length; i++) {
const extra = pair[i];
const { error: extraError } = await auditedSupabase.insert(
'questions',
[{
pack_id: packId,
text: extra.text,
partner_text: extra.partner_text || null,
intensity: extra.intensity,
inverse_of: null,
}]
);
if (extraError) throw extraError;
}
} else if (pair.length === 1) {
// Single question with pair_id but no pair - treat as standalone
const { error } = await auditedSupabase.insert(
'questions',
[{
pack_id: packId,
text: pair[0].text,
partner_text: pair[0].partner_text || null,
intensity: pair[0].intensity,
inverse_of: null,
}]
);
if (error) throw error;
}
}
toast.success(`${generatedQuestions.length} questions added`);
fetchData();
} catch (error) {
@@ -272,6 +432,28 @@ export function QuestionsPage() {
setAiDialogOpen(false);
};
const handleReviewOpen = async () => {
if (!packId) return;
setReviewLoading(true);
try {
const { data, error } = await supabase
.from('questions')
.select('id, pack_id, text, partner_text, intensity, allowed_couple_genders, target_user_genders, required_props, created_at')
.eq('pack_id', packId)
.order('created_at', { ascending: true });
if (error) throw error;
setReviewQuestions(data || []);
setReviewOpen(true);
} catch (error) {
toast.error('Failed to load questions for review');
console.error(error);
} finally {
setReviewLoading(false);
}
};
// Selection handlers
const toggleSelection = (id: string) => {
const next = new Set(selectedIds);
@@ -315,6 +497,13 @@ export function QuestionsPage() {
}
};
const handlePeekPrimary = (question: Question) => {
if (!question.inverse_of) return;
setPeekPrimary(primaryQuestionById.get(question.inverse_of) ?? null);
setPeekInverse(question);
setPeekOpen(true);
};
if (loading) {
return (
<div className="space-y-6">
@@ -348,8 +537,12 @@ export function QuestionsPage() {
</p>
</div>
<div className="flex gap-2">
<Button variant="outline" onClick={() => setReviewOpen(true)}>
<Button variant="outline" onClick={handleReviewOpen} disabled={reviewLoading}>
{reviewLoading ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Sparkles className="mr-2 h-4 w-4" />
)}
Review Questions
</Button>
<Button variant="outline" onClick={() => setAiDialogOpen(true)}>
@@ -442,6 +635,52 @@ export function QuestionsPage() {
</div>
</div>
<div className="space-y-2">
<Label htmlFor="required_props">Required Props (optional)</Label>
<Input
id="required_props"
value={formData.required_props}
onChange={(e) => setFormData(d => ({ ...d, required_props: e.target.value }))}
placeholder="e.g., vibrator, restraints"
/>
<p className="text-xs text-muted-foreground">
Comma-separated list of props/accessories needed for this question.
</p>
</div>
<div className="space-y-2">
<div className="flex items-center gap-2">
<Label htmlFor="inverse_of">Inverse Of (optional)</Label>
<Badge variant="outline" className="text-xs">
<Link2 className="h-3 w-3 mr-1" />
Paired
</Badge>
</div>
<Select
value={formData.inverse_of || 'none'}
onValueChange={(value) => setFormData(d => ({ ...d, inverse_of: value === 'none' ? '' : value }))}
>
<SelectTrigger>
<SelectValue placeholder="Select primary question..." />
</SelectTrigger>
<SelectContent className="max-h-60">
<SelectItem value="none">
<span className="text-muted-foreground">None (this is a standalone question)</span>
</SelectItem>
{allPackQuestions
.filter(q => q.id !== editingQuestion?.id && !q.inverse_of)
.map(q => (
<SelectItem key={q.id} value={q.id}>
<span className="line-clamp-1">{q.text}</span>
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">
Link this question to its inverse (e.g., "give X" → "receive X"). The selected question is the primary; this one becomes its inverse.
Only standalone questions can be selected as primaries.
</p>
</div>
<div className="space-y-2">
<Label>Target Couples (optional)</Label>
@@ -580,11 +819,38 @@ export function QuestionsPage() {
<ReviewQuestionsDialog
open={reviewOpen}
onOpenChange={setReviewOpen}
questions={questions}
questions={reviewQuestions}
isExplicit={pack?.is_explicit ?? false}
onUpdated={fetchData}
/>
<Dialog open={peekOpen} onOpenChange={setPeekOpen}>
<DialogContent className="max-w-xl">
<DialogHeader>
<DialogTitle>Primary Question</DialogTitle>
<DialogDescription>
Quick peek for the inverse question link.
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-1">
<p className="text-sm font-medium">Primary</p>
<p className="text-sm text-muted-foreground whitespace-pre-wrap">
{peekPrimary?.text || 'Primary question not found.'}
</p>
</div>
{peekInverse && (
<div className="space-y-1">
<p className="text-sm font-medium">Inverse</p>
<p className="text-sm text-muted-foreground whitespace-pre-wrap">
{peekInverse.text}
</p>
</div>
)}
</div>
</DialogContent>
</Dialog>
{/* Questions Table */}
{
questions.length === 0 ? (
@@ -619,6 +885,7 @@ export function QuestionsPage() {
</TableHead>
<TableHead className="w-12">#</TableHead>
<TableHead>Question</TableHead>
<TableHead className="w-20">Inverse</TableHead>
<TableHead className="w-32">Partner Text</TableHead>
<TableHead className="w-24">Couples</TableHead>
<TableHead className="w-24">Initiator</TableHead>
@@ -645,6 +912,33 @@ export function QuestionsPage() {
<TableCell>
<span className="line-clamp-2">{question.text}</span>
</TableCell>
<TableCell>
{question.inverse_of ? (
<div className="flex items-center gap-2">
<Badge variant="outline" className="text-xs border-blue-500 text-blue-500" title="This is an inverse of another question">
<Link2 className="h-3 w-3 mr-1" />
Inverse
</Badge>
<Button
variant="ghost"
size="icon"
className="h-6 w-6"
onClick={() => handlePeekPrimary(question)}
aria-label="Peek primary question"
title="Peek primary question"
>
<Eye className="h-3 w-3" />
</Button>
</div>
) : questions.some(q => q.inverse_of === question.id) ? (
<Badge variant="outline" className="text-xs border-green-500 text-green-500" title="This question has an inverse">
<Link2 className="h-3 w-3 mr-1" />
Primary
</Badge>
) : (
<span className="text-muted-foreground text-xs">—</span>
)}
</TableCell>
<TableCell>
{question.partner_text ? (
<Badge variant="secondary" className="text-xs">
+459
View File
@@ -0,0 +1,459 @@
import { useCallback, useEffect, useState } from 'react';
import { supabase } from '@/config';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Skeleton } from '@/components/ui/skeleton';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { toast } from 'sonner';
import { CheckCircle2, Loader2, Sparkles, Tags } from 'lucide-react';
import { extractTopicsFromPack } from '@/lib/openai';
interface TopicRow {
id: string;
name: string;
}
interface PackTopicRow {
pack_id: string;
topic_id: string;
}
interface PackRow {
id: string;
name: string;
description: string | null;
is_public: boolean;
category?: { name: string } | { name: string }[] | null;
}
interface ActiveTag {
id: string;
name: string;
packCount: number;
}
interface UntaggedPack {
id: string;
name: string;
description: string | null;
is_public: boolean;
categoryName: string;
questionCount: number;
}
const getCategoryName = (category?: PackRow['category']) => {
if (!category) return 'Uncategorized';
if (Array.isArray(category)) {
return category[0]?.name || 'Uncategorized';
}
return category.name || 'Uncategorized';
};
export function TagsPage() {
const [activeTags, setActiveTags] = useState<ActiveTag[]>([]);
const [untaggedPacks, setUntaggedPacks] = useState<UntaggedPack[]>([]);
const [loading, setLoading] = useState(true);
const [taggingAll, setTaggingAll] = useState(false);
const [taggingProgress, setTaggingProgress] = useState<{
current: number;
total: number;
packName: string;
} | null>(null);
const fetchData = useCallback(async () => {
setLoading(true);
try {
const [topicsResult, packTopicsResult, packsResult] = await Promise.all([
supabase.from('topics').select('id, name').order('name'),
supabase.from('pack_topics').select('pack_id, topic_id'),
supabase
.from('question_packs')
.select('id, name, description, is_public, category:categories(name)')
.order('name'),
]);
if (topicsResult.error) throw topicsResult.error;
if (packTopicsResult.error) throw packTopicsResult.error;
if (packsResult.error) throw packsResult.error;
const topics = (topicsResult.data || []) as TopicRow[];
const packTopics = (packTopicsResult.data || []) as PackTopicRow[];
const packs = (packsResult.data || []) as PackRow[];
const topicCounts = new Map<string, number>();
const packIdsWithTopics = new Set<string>();
packTopics.forEach((relation) => {
if (!relation.topic_id || !relation.pack_id) return;
packIdsWithTopics.add(relation.pack_id);
topicCounts.set(relation.topic_id, (topicCounts.get(relation.topic_id) || 0) + 1);
});
const activeTagList = topics
.filter((topic) => topicCounts.has(topic.id))
.map((topic) => ({
id: topic.id,
name: topic.name,
packCount: topicCounts.get(topic.id) || 0,
}))
.sort((a, b) => {
if (b.packCount !== a.packCount) return b.packCount - a.packCount;
return a.name.localeCompare(b.name);
});
const untagged = packs.filter((pack) => !packIdsWithTopics.has(pack.id));
const untaggedPackIds = untagged.map((pack) => pack.id);
const questionCounts: Record<string, number> = {};
if (untaggedPackIds.length > 0) {
const { data: questions, error: questionsError } = await supabase
.from('questions')
.select('pack_id')
.in('pack_id', untaggedPackIds);
if (questionsError) throw questionsError;
questions?.forEach((question) => {
if (!question.pack_id) return;
questionCounts[question.pack_id] = (questionCounts[question.pack_id] || 0) + 1;
});
}
setActiveTags(activeTagList);
setUntaggedPacks(
untagged.map((pack) => ({
id: pack.id,
name: pack.name,
description: pack.description,
is_public: pack.is_public,
categoryName: getCategoryName(pack.category),
questionCount: questionCounts[pack.id] || 0,
}))
);
} catch (error) {
toast.error('Failed to load tags');
console.error(error);
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
fetchData();
}, [fetchData]);
const eligiblePackCount = untaggedPacks.filter((pack) => pack.questionCount > 0).length;
const handleTagAll = async () => {
if (taggingAll || eligiblePackCount === 0) return;
const eligiblePacks = untaggedPacks.filter((pack) => pack.questionCount > 0);
const skippedNoQuestions = untaggedPacks.length - eligiblePacks.length;
if (eligiblePacks.length === 0) {
toast('No untagged packs with questions to tag');
return;
}
const shouldProceed = confirm(
`Tag ${eligiblePacks.length} untagged pack${eligiblePacks.length !== 1 ? 's' : ''} with AI?` +
(skippedNoQuestions > 0
? ` ${skippedNoQuestions} pack${skippedNoQuestions !== 1 ? 's' : ''} without questions will be skipped.`
: '')
);
if (!shouldProceed) return;
setTaggingAll(true);
setTaggingProgress({ current: 0, total: eligiblePacks.length, packName: '' });
try {
const { data: topicsData, error: topicsError } = await supabase
.from('topics')
.select('id, name')
.order('name');
if (topicsError) throw topicsError;
const existingTopics: TopicRow[] = [...(topicsData || [])];
let taggedCount = 0;
let skippedCount = 0;
let failedCount = 0;
for (let index = 0; index < eligiblePacks.length; index += 1) {
const pack = eligiblePacks[index];
setTaggingProgress({
current: index + 1,
total: eligiblePacks.length,
packName: pack.name,
});
try {
const { data: questions, error: questionsError } = await supabase
.from('questions')
.select('text')
.eq('pack_id', pack.id);
if (questionsError) throw questionsError;
if (!questions || questions.length === 0) {
skippedCount += 1;
continue;
}
const result = await extractTopicsFromPack(
pack.name,
pack.description,
questions.map((question) => question.text),
existingTopics
);
if (!result.topics || result.topics.length === 0) {
skippedCount += 1;
continue;
}
const existingByName = new Map(
existingTopics.map((topic) => [topic.name.toLowerCase(), topic.id])
);
const newTopics: { name: string }[] = [];
const topicIds: string[] = [];
result.topics.forEach((topic) => {
const normalizedName = topic.name.trim();
if (!normalizedName) return;
if (!topic.isNew && topic.existingTopicId) {
topicIds.push(topic.existingTopicId);
return;
}
const existingId = existingByName.get(normalizedName.toLowerCase());
if (existingId) {
topicIds.push(existingId);
return;
}
newTopics.push({ name: normalizedName });
});
if (newTopics.length > 0) {
const { data: createdTopics, error: createError } = await supabase
.from('topics')
.insert(newTopics)
.select('id, name');
if (createError) throw createError;
createdTopics?.forEach((topic) => {
topicIds.push(topic.id);
existingTopics.push({ id: topic.id, name: topic.name });
});
}
await supabase.from('pack_topics').delete().eq('pack_id', pack.id);
if (topicIds.length === 0) {
skippedCount += 1;
continue;
}
const { error: linkError } = await supabase
.from('pack_topics')
.insert(topicIds.map((topicId) => ({
pack_id: pack.id,
topic_id: topicId,
})));
if (linkError) throw linkError;
taggedCount += 1;
} catch (error) {
failedCount += 1;
console.error(`Failed to tag pack ${pack.name}:`, error);
}
}
if (failedCount > 0) {
toast.error(`Tagged ${taggedCount} pack${taggedCount !== 1 ? 's' : ''}. ${failedCount} failed.`);
} else {
toast.success(
`Tagged ${taggedCount} pack${taggedCount !== 1 ? 's' : ''}.` +
(skippedCount > 0 ? ` ${skippedCount} skipped.` : '')
);
}
} catch (error) {
toast.error('Failed to tag all packs');
console.error(error);
} finally {
setTaggingAll(false);
setTaggingProgress(null);
fetchData();
}
};
if (loading) {
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<Skeleton className="h-9 w-64" />
<Skeleton className="h-10 w-40" />
</div>
<div className="grid gap-4 md:grid-cols-2">
<Skeleton className="h-32" />
<Skeleton className="h-32" />
</div>
<div className="grid gap-6 lg:grid-cols-2">
<Skeleton className="h-64" />
<Skeleton className="h-64" />
</div>
</div>
);
}
return (
<div className="space-y-6">
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<div>
<h1 className="text-3xl font-bold tracking-tight flex items-center gap-2">
<Tags className="h-8 w-8 text-primary" />
Tags
</h1>
<p className="text-muted-foreground">
Active tags and packs that still need topic coverage
</p>
</div>
<div className="flex flex-col items-start gap-2 sm:items-end">
<Button
onClick={handleTagAll}
disabled={taggingAll || eligiblePackCount === 0}
>
{taggingAll ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Sparkles className="mr-2 h-4 w-4" />
)}
{taggingAll && taggingProgress
? `Tagging ${taggingProgress.current}/${taggingProgress.total}`
: 'Tag All Untagged'}
</Button>
{taggingProgress?.packName && (
<p className="text-xs text-muted-foreground">
Working on {taggingProgress.packName}
</p>
)}
{eligiblePackCount === 0 && untaggedPacks.length > 0 && (
<p className="text-xs text-muted-foreground">
Add questions to enable auto-tagging.
</p>
)}
</div>
</div>
<div className="grid gap-4 md:grid-cols-2">
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Active Tags</CardTitle>
<Tags className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{activeTags.length.toLocaleString()}</div>
<p className="text-xs text-muted-foreground">Tags assigned to at least one pack</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Untagged Packs</CardTitle>
<Sparkles className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{untaggedPacks.length.toLocaleString()}</div>
<p className="text-xs text-muted-foreground">Packs missing any tag coverage</p>
</CardContent>
</Card>
</div>
<div className="grid gap-6 lg:grid-cols-2">
<Card>
<CardHeader>
<CardTitle>Active Tags</CardTitle>
<CardDescription>Tag usage across question packs</CardDescription>
</CardHeader>
<CardContent>
{activeTags.length === 0 ? (
<div className="flex flex-col items-center justify-center py-10 text-muted-foreground">
<CheckCircle2 className="h-10 w-10 mb-2" />
No active tags yet
</div>
) : (
<div className="space-y-3">
{activeTags.map((tag) => (
<div
key={tag.id}
className="flex items-center justify-between rounded-lg border px-4 py-3"
>
<p className="font-medium">{tag.name}</p>
<Badge variant="secondary">
{tag.packCount} pack{tag.packCount !== 1 ? 's' : ''}
</Badge>
</div>
))}
</div>
)}
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<div>
<CardTitle>Untagged Packs</CardTitle>
<CardDescription>These packs still need topics</CardDescription>
</div>
<Badge variant="outline" className="text-xs">
{untaggedPacks.length} total
</Badge>
</CardHeader>
<CardContent>
{untaggedPacks.length === 0 ? (
<div className="flex flex-col items-center justify-center py-10 text-muted-foreground">
<CheckCircle2 className="h-10 w-10 mb-2" />
All packs are tagged
</div>
) : (
<div className="space-y-3">
{untaggedPacks.map((pack) => (
<div
key={pack.id}
className="flex items-center justify-between rounded-lg border px-4 py-3"
>
<div>
<div className="flex flex-wrap items-center gap-2">
<p className="font-medium">{pack.name}</p>
{pack.is_public ? (
<Badge variant="outline" className="border-green-500 text-green-400">
Published
</Badge>
) : (
<Badge variant="secondary">Draft</Badge>
)}
{pack.questionCount === 0 && (
<Badge variant="outline" className="border-yellow-500 text-yellow-400">
No questions
</Badge>
)}
</div>
<p className="text-xs text-muted-foreground">{pack.categoryName}</p>
</div>
<div className="text-right">
<p className="text-lg font-semibold">{pack.questionCount}</p>
<p className="text-xs text-muted-foreground">questions</p>
</div>
</div>
))}
</div>
)}
</CardContent>
</Card>
</div>
</div>
);
}
+6 -1
View File
@@ -12,6 +12,7 @@ export interface Category {
icon: string | null;
sort_order: number | null;
created_at: string | null;
is_public: boolean;
pack_count?: number;
}
@@ -19,6 +20,7 @@ export interface CategoryFormData {
name: string;
description: string;
icon: string;
is_public: boolean;
}
// =============================================================================
@@ -85,10 +87,12 @@ export async function createCategory(
description: data.description || null,
icon: data.icon || null,
sort_order: sortOrder,
is_public: data.is_public ?? true,
});
if (error) throw error;
return created as Category;
if (!created || created.length === 0) throw new Error('Failed to create category');
return created[0] as Category;
}
/**
@@ -102,6 +106,7 @@ export async function updateCategory(
name: data.name,
description: data.description || null,
icon: data.icon || null,
is_public: data.is_public,
});
if (error) throw error;
+2 -1
View File
@@ -132,7 +132,8 @@ export async function createPack(
});
if (error) throw error;
return created as Pack;
if (!created || created.length === 0) throw new Error('Failed to create pack');
return created[0] as Pack;
}
/**
+111 -12
View File
@@ -12,6 +12,7 @@ export interface Question {
intensity: number;
pack_id: string;
created_at: string | null;
deleted_at: string | null;
}
export interface QuestionFormData {
@@ -25,15 +26,21 @@ export interface QuestionFormData {
// =============================================================================
/**
* Fetch all questions for a pack
* Fetch all questions for a pack (excludes soft-deleted by default)
*/
export async function fetchQuestionsForPack(packId: string): Promise<Question[]> {
const { data, error } = await supabase
export async function fetchQuestionsForPack(packId: string, includeDeleted = false): Promise<Question[]> {
let query = supabase
.from('questions')
.select('*')
.eq('pack_id', packId)
.order('created_at', { ascending: true });
if (!includeDeleted) {
query = query.is('deleted_at', null);
}
const { data, error } = await query;
if (error) throw error;
return data || [];
}
@@ -53,13 +60,14 @@ export async function fetchQuestionById(id: string): Promise<Question | null> {
}
/**
* Fetch question count for a pack
* Fetch question count for a pack (excludes soft-deleted)
*/
export async function fetchQuestionCount(packId: string): Promise<number> {
const { count, error } = await supabase
.from('questions')
.select('*', { count: 'exact', head: true })
.eq('pack_id', packId);
.eq('pack_id', packId)
.is('deleted_at', null);
if (error) throw error;
return count || 0;
@@ -84,7 +92,8 @@ export async function createQuestion(
});
if (error) throw error;
return created as Question;
if (!created || created.length === 0) throw new Error('Failed to create question');
return created[0] as Question;
}
/**
@@ -104,41 +113,131 @@ export async function updateQuestion(
}
/**
* Delete a question
* Soft delete a question (sets deleted_at timestamp)
*/
export async function deleteQuestion(id: string): Promise<void> {
const { error } = await auditedSupabase.delete('questions', id);
const { error } = await auditedSupabase.update('questions', id, {
deleted_at: new Date().toISOString(),
});
if (error) throw error;
}
/**
* Delete multiple questions
* Soft delete multiple questions
*/
export async function deleteQuestions(ids: string[]): Promise<void> {
await Promise.all(
ids.map(id => auditedSupabase.delete('questions', id))
ids.map(id => auditedSupabase.update('questions', id, {
deleted_at: new Date().toISOString(),
}))
);
}
/**
* Restore a soft-deleted question
*/
export async function restoreQuestion(id: string): Promise<void> {
const { error } = await auditedSupabase.update('questions', id, {
deleted_at: null,
});
if (error) throw error;
}
/**
* Bulk create questions (for AI generation)
* Handles inverse_pair_id by grouping pairs and setting inverse_of links
*/
export async function bulkCreateQuestions(
packId: string,
questions: Array<{ text: string; partner_text?: string; intensity: number }>
questions: Array<{ text: string; partner_text?: string; intensity: number; inverse_pair_id?: string | null }>
): Promise<Question[]> {
const results: Question[] = [];
// Group questions by inverse_pair_id
const pairMap = new Map<string, typeof questions>();
const standaloneQuestions: typeof questions = [];
for (const q of questions) {
if (q.inverse_pair_id) {
const existing = pairMap.get(q.inverse_pair_id) || [];
existing.push(q);
pairMap.set(q.inverse_pair_id, existing);
} else {
standaloneQuestions.push(q);
}
}
// Insert standalone questions first
for (const q of standaloneQuestions) {
const { data, error } = await auditedSupabase.insert('questions', {
pack_id: packId,
text: q.text,
partner_text: q.partner_text || null,
intensity: q.intensity,
inverse_of: null,
});
if (error) throw error;
if (data) results.push(data as Question);
if (data && data.length > 0) results.push(data[0] as Question);
}
// Insert paired questions: primary first, then inverse with inverse_of link
for (const [, pair] of pairMap) {
if (pair.length >= 2) {
// First question is primary
const primary = pair[0];
const { data: primaryData, error: primaryError } = await auditedSupabase.insert('questions', {
pack_id: packId,
text: primary.text,
partner_text: primary.partner_text || null,
intensity: primary.intensity,
inverse_of: null,
});
if (primaryError) throw primaryError;
if (primaryData && primaryData.length > 0) {
results.push(primaryData[0] as Question);
const primaryId = primaryData[0].id;
// Second question is inverse
const inverse = pair[1];
const { data: inverseData, error: inverseError } = await auditedSupabase.insert('questions', {
pack_id: packId,
text: inverse.text,
partner_text: inverse.partner_text || null,
intensity: inverse.intensity,
inverse_of: primaryId,
});
if (inverseError) throw inverseError;
if (inverseData && inverseData.length > 0) results.push(inverseData[0] as Question);
}
// Handle any additional questions in the pair
for (let i = 2; i < pair.length; i++) {
const extra = pair[i];
const { data, error } = await auditedSupabase.insert('questions', {
pack_id: packId,
text: extra.text,
partner_text: extra.partner_text || null,
intensity: extra.intensity,
inverse_of: null,
});
if (error) throw error;
if (data && data.length > 0) results.push(data[0] as Question);
}
} else if (pair.length === 1) {
// Single question with pair_id but no pair
const { data, error } = await auditedSupabase.insert('questions', {
pack_id: packId,
text: pair[0].text,
partner_text: pair[0].partner_text || null,
intensity: pair[0].intensity,
inverse_of: null,
});
if (error) throw error;
if (data && data.length > 0) results.push(data[0] as Question);
}
}
return results;
+4
View File
@@ -0,0 +1,4 @@
PORT=3000
SUPABASE_URL=https://your-project.supabase.co
SUPABASE_SERVICE_ROLE_KEY=your-service-role-key
SAUCI_MCP_API_KEY=your-secret-api-key
+14
View File
@@ -0,0 +1,14 @@
FROM node:22-slim AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM node:22-slim
WORKDIR /app
COPY --from=build /app/dist ./dist
COPY --from=build /app/node_modules ./node_modules
COPY --from=build /app/package.json .
EXPOSE 3000
CMD ["node", "dist/index.mjs"]
+2785
View File
File diff suppressed because it is too large. Load diff
+26
View File
@@ -0,0 +1,26 @@
{
"name": "sauci-mcp-server",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "tsx watch src/index.ts",
"build": "tsup src/index.ts src/bridge.ts --format esm --clean",
"start": "node dist/index.mjs",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@hono/mcp": "^0.1.1",
"@modelcontextprotocol/sdk": "^1.0.1",
"@supabase/supabase-js": "^2.45.0",
"dotenv": "^16.4.5",
"eventsource": "^4.1.0",
"hono": "^4.6.0",
"zod": "^3.23.0"
},
"devDependencies": {
"@types/node": "^22.0.0",
"tsup": "^8.2.4",
"tsx": "^4.19.0",
"typescript": "^5.5.0"
}
}
+82
View File
@@ -0,0 +1,82 @@
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';
const args = process.argv.slice(2);
const urlArg = args.find(a => a.startsWith('--url='));
const keyArg = args.find(a => a.startsWith('--key='));
if (!urlArg || !keyArg) {
console.error('Usage: node bridge.js --url=<url> --key=<key>');
process.exit(1);
}
const url = new URL(urlArg.split('=')[1]);
const apiKey = keyArg.split('=')[1];
async function run() {
console.error(`[Bridge] Connecting to ${url.href}`);
console.error(`[Bridge] Using API Key (length: ${apiKey.length})`);
const local = new StdioServerTransport();
const remote = new StreamableHTTPClientTransport(url, {
requestInit: {
headers: {
'Authorization': `Bearer ${apiKey}`,
'Accept': 'application/json, text/event-stream'
}
}
});
// Bridge messages from Claude Desktop to remote server
local.onmessage = async (message) => {
console.error(`[Bridge] -> Remote: ${JSON.stringify(message).slice(0, 100)}...`);
try {
await remote.send(message);
} catch (err) {
console.error('[Bridge] Failed to forward message to remote:', err);
}
};
// Bridge messages from remote server to Claude Desktop
remote.onmessage = async (message) => {
console.error(`[Bridge] <- Remote: ${JSON.stringify(message).slice(0, 100)}...`);
try {
await local.send(message);
} catch (err) {
console.error('[Bridge] Failed to forward message to local:', err);
}
};
remote.onerror = (err) => {
console.error('[Bridge] Remote transport error:', err);
};
local.onerror = (err) => {
console.error('[Bridge] Local transport error:', err);
process.exit(1);
};
local.onclose = () => {
console.error('[Bridge] Local transport closed');
process.exit(0);
};
remote.onclose = () => {
console.error('[Bridge] Remote transport closed');
};
// Start both transports
console.error('[Bridge] Starting remote transport...');
await remote.start();
console.error('[Bridge] Remote transport started');
console.error('[Bridge] Starting local transport...');
await local.start();
console.error('[Bridge] Local transport started - ready for messages');
}
run().catch(err => {
console.error('[Bridge] Fatal error:', err);
process.exit(1);
});
+29
View File
@@ -0,0 +1,29 @@
import { serve } from '@hono/node-server';
import { Hono } from 'hono';
import { logger } from 'hono/logger';
import { authMiddleware } from './lib/auth.js';
import { createMcpServer } from './server.js';
const app = new Hono();
app.use('*', logger());
app.use('/mcp', authMiddleware);
app.use('/mcp/*', authMiddleware);
app.get('/health', (c) => c.json({ status: 'ok', service: 'sauci-mcp' }));
// Initialize MCP server with StreamableHTTP transport
const mcpServer = createMcpServer();
// StreamableHTTP endpoint - handles all MCP communication
app.all('/mcp', async (c) => {
return mcpServer.handleRequest(c);
});
const port = Number(process.env.PORT) || 3000;
console.log(`Server is running on port ${port}`);
serve({
fetch: app.fetch,
port
});
+29
View File
@@ -0,0 +1,29 @@
import { Context, Next } from 'hono';
export const authMiddleware = async (c: Context, next: Next) => {
const authHeader = c.req.header('Authorization');
const apiKey = process.env.SAUCI_MCP_API_KEY;
if (!apiKey) {
// If no API key is set in env, allow access (warn in logs)
// Ideally this should default to deny, but for dev ease we might want to warn
console.warn('SAUCI_MCP_API_KEY not set in environment - allowing all requests');
return next();
}
let token = c.req.header('Authorization')?.replace('Bearer ', '');
if (!token) {
token = c.req.query('apiKey');
}
if (!token) {
return c.json({ error: 'Unauthorized: Missing or invalid Authorization header or apiKey query param' }, 401);
}
if (token !== apiKey) {
return c.json({ error: 'Unauthorized: Invalid API key' }, 401);
}
return next();
};
+22
View File
@@ -0,0 +1,22 @@
import { createClient } from '@supabase/supabase-js';
import { config } from 'dotenv';
config();
const supabaseUrl = process.env.SUPABASE_URL;
const supabaseServiceKey = process.env.SUPABASE_SERVICE_ROLE_KEY;
if (!supabaseUrl || !supabaseServiceKey) {
console.warn('Missing SUPABASE_URL or SUPABASE_SERVICE_ROLE_KEY environment variables');
}
export const supabase = createClient(
supabaseUrl || '',
supabaseServiceKey || '',
{
auth: {
autoRefreshToken: false,
persistSession: false
}
}
);
+37
View File
@@ -0,0 +1,37 @@
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { StreamableHTTPTransport } from '@hono/mcp';
import { registerContentTools } from './tools/content.js';
import { registerUserTools } from './tools/users.js';
import { registerModerationTools } from './tools/moderation.js';
import { registerCodeTools } from './tools/codes.js';
import { registerFeedbackTools } from './tools/feedback.js';
import { registerAnalyticsTools } from './tools/analytics.js';
import { registerConfigTools } from './tools/config.js';
import { registerAdminTools } from './tools/admin.js';
import { Context } from 'hono';
export function createMcpServer() {
const server = new McpServer({
name: 'sauci-admin',
version: '1.0.0',
});
// Register all tool groups
registerContentTools(server);
registerUserTools(server);
registerModerationTools(server);
registerCodeTools(server);
registerFeedbackTools(server);
registerAnalyticsTools(server);
registerConfigTools(server);
registerAdminTools(server);
return {
handleRequest: async (c: Context) => {
const transport = new StreamableHTTPTransport();
await server.connect(transport);
return transport.handleRequest(c);
}
};
}
+150
View File
@@ -0,0 +1,150 @@
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { z } from 'zod';
import { supabase } from '../lib/supabase.js';
import { logAudit } from '../utils/audit.js';
export function registerAdminTools(server: McpServer) {
server.tool(
'list_admins',
'List all admin users',
{},
async () => {
const { data, error } = await supabase
.from('admin_users')
.select(`
*,
user:user_id (name, email, avatar_url)
`);
if (error) throw new Error(`Failed to list admins: ${error.message}`);
return {
content: [{ type: 'text', text: JSON.stringify(data, null, 2) }]
};
}
);
server.tool(
'add_admin',
'Grant admin access to a user',
{
user_id: z.string(),
role: z.enum(['super_admin', 'pack_creator']).default('pack_creator'),
permissions: z.array(z.string()).optional()
},
async ({ user_id, role, permissions }) => {
const { data, error } = await supabase
.from('admin_users')
.insert({
user_id,
role,
permissions: permissions || []
})
.select()
.single();
if (error) throw new Error(`Failed to add admin: ${error.message}`);
await logAudit({
action: 'INSERT',
table_name: 'admin_users',
record_id: data.id,
new_values: data
});
return {
content: [{ type: 'text', text: JSON.stringify(data, null, 2) }]
};
}
);
server.tool(
'update_admin_permissions',
'Update admin permissions',
{
id: z.string(), // admin_users ID, not user_id
role: z.enum(['super_admin', 'pack_creator']).optional(),
permissions: z.array(z.string()).optional()
},
async ({ id, role, permissions }) => {
const { data: oldData } = await supabase.from('admin_users').select('*').eq('id', id).single();
const updates: any = {};
if (role) updates.role = role;
if (permissions) updates.permissions = permissions;
const { data, error } = await supabase
.from('admin_users')
.update(updates)
.eq('id', id)
.select()
.single();
if (error) throw new Error(`Failed to update admin: ${error.message}`);
await logAudit({
action: 'UPDATE',
table_name: 'admin_users',
record_id: id,
old_values: oldData,
new_values: data
});
return {
content: [{ type: 'text', text: JSON.stringify(data, null, 2) }]
};
}
);
server.tool(
'remove_admin',
'Revoke admin access',
{ id: z.string() }, // admin_users ID
async ({ id }) => {
const { data: oldData } = await supabase.from('admin_users').select('*').eq('id', id).single();
const { error } = await supabase.from('admin_users').delete().eq('id', id);
if (error) throw new Error(`Failed to remove admin: ${error.message}`);
await logAudit({
action: 'DELETE',
table_name: 'admin_users',
record_id: id,
old_values: oldData
});
return {
content: [{ type: 'text', text: `Admin ${id} removed successfully` }]
};
}
);
server.tool(
'list_audit_logs',
'View audit logs',
{
limit: z.number().default(20),
offset: z.number().default(0),
table_name: z.string().optional(),
action: z.enum(['INSERT', 'UPDATE', 'DELETE']).optional()
},
async ({ limit, offset, table_name, action }) => {
let query = supabase
.from('audit_logs')
.select('*', { count: 'exact' });
if (table_name) query = query.eq('table_name', table_name);
if (action) query = query.eq('action', action);
const { data, error, count } = await query
.order('created_at', { ascending: false })
.range(offset, offset + limit - 1);
if (error) throw new Error(`Failed to list audit logs: ${error.message}`);
return {
content: [{ type: 'text', text: JSON.stringify({ data, count }, null, 2) }]
};
}
);
}
+116
View File
@@ -0,0 +1,116 @@
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { supabase } from '../lib/supabase.js';
export function registerAnalyticsTools(server: McpServer) {
server.tool(
'get_dashboard_stats',
'Get high-level dashboard statistics',
{},
async () => {
// Parallel fetch for counts
const [
{ count: categories },
{ count: packs },
{ count: questions },
{ count: profiles },
{ count: couples }
] = await Promise.all([
supabase.from('categories').select('*', { count: 'exact', head: true }),
supabase.from('question_packs').select('*', { count: 'exact', head: true }),
supabase.from('questions').select('*', { count: 'exact', head: true }),
supabase.from('profiles').select('*', { count: 'exact', head: true }),
supabase.from('couples').select('*', { count: 'exact', head: true })
]);
return {
content: [{ type: 'text', text: JSON.stringify({
counts: {
categories,
packs,
questions,
profiles,
couples
}
}, null, 2) }]
};
}
);
server.tool(
'get_feature_interests',
'Get feature interest stats',
{},
async () => {
const { data, error } = await supabase.rpc('get_feature_interest_counts');
// Fallback if RPC doesn't exist or fails, query table directly if possible
if (error) {
// Try raw query on feature_interests table
const { data: rawData, error: rawError } = await supabase
.from('feature_interests')
.select('feature_name');
if (rawError) throw new Error(`Failed to get feature interests: ${rawError.message}`);
// Manual aggregation
const counts: Record<string, number> = {};
rawData.forEach((r: any) => {
counts[r.feature_name] = (counts[r.feature_name] || 0) + 1;
});
return {
content: [{ type: 'text', text: JSON.stringify(counts, null, 2) }]
};
}
return {
content: [{ type: 'text', text: JSON.stringify(data, null, 2) }]
};
}
);
server.tool(
'get_usage_insights',
'Get usage insights (join reasons, etc)',
{},
async () => {
// Aggregating onboarding data from profiles
const { data, error } = await supabase
.from('profiles')
.select('usage_reason, gender');
if (error) throw new Error(`Failed to get usage insights: ${error.message}`);
const reasons: Record<string, number> = {};
const genders: Record<string, number> = {};
data.forEach((p) => {
if (p.usage_reason) reasons[p.usage_reason] = (reasons[p.usage_reason] || 0) + 1;
if (p.gender) genders[p.gender] = (genders[p.gender] || 0) + 1;
});
return {
content: [{ type: 'text', text: JSON.stringify({ reasons, genders }, null, 2) }]
};
}
);
server.tool(
'get_question_analytics',
'Get question performance stats',
{},
async () => {
// Limit to top 50 most answered questions
// This is heavy, ideally we'd have an RPC or materialized view
// For now, we'll just return a placeholder or simple query
// Let's get response counts per question
// Using .rpc if available would be best.
// I'll assume we don't have a specific RPC for this yet and do a lightweight check.
return {
content: [{ type: 'text', text: "Question analytics requires complex aggregation. Please use the 'list_questions' tool to inspect specific questions." }]
};
}
);
}
+153
View File
@@ -0,0 +1,153 @@
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { z } from 'zod';
import { supabase } from '../lib/supabase.js';
import { logAudit } from '../utils/audit.js';
export function registerCodeTools(server: McpServer) {
server.tool(
'list_redemption_codes',
'List redemption codes',
{
page: z.number().default(1),
limit: z.number().default(20),
search: z.string().optional()
},
async ({ page, limit, search }) => {
const offset = (page - 1) * limit;
let query = supabase
.from('redemption_codes')
.select('*', { count: 'exact' });
if (search) {
query = query.or(`code.ilike.%${search}%,description.ilike.%${search}%`);
}
const { data, error, count } = await query
.range(offset, offset + limit - 1)
.order('created_at', { ascending: false });
if (error) throw new Error(`Failed to list codes: ${error.message}`);
return {
content: [{ type: 'text', text: JSON.stringify({ data, count }, null, 2) }]
};
}
);
server.tool(
'generate_code',
'Create a new redemption code',
{
code: z.string().optional(), // If not provided, should generate one
description: z.string().optional(),
max_uses: z.number().default(1),
expires_at: z.string().optional(), // ISO date string
is_active: z.boolean().default(true)
},
async ({ code, description, max_uses, expires_at, is_active }) => {
const finalCode = code || Math.random().toString(36).substring(2, 10).toUpperCase();
const { data, error } = await supabase
.from('redemption_codes')
.insert({
code: finalCode,
description,
max_uses,
expires_at,
is_active,
current_uses: 0
})
.select()
.single();
if (error) throw new Error(`Failed to create code: ${error.message}`);
await logAudit({
action: 'INSERT',
table_name: 'redemption_codes',
record_id: data.id,
new_values: data
});
return {
content: [{ type: 'text', text: JSON.stringify(data, null, 2) }]
};
}
);
server.tool(
'toggle_code_active',
'Enable or disable a redemption code',
{ id: z.string(), is_active: z.boolean() },
async ({ id, is_active }) => {
const { data: oldData } = await supabase.from('redemption_codes').select('*').eq('id', id).single();
const { data, error } = await supabase
.from('redemption_codes')
.update({ is_active })
.eq('id', id)
.select()
.single();
if (error) throw new Error(`Failed to update code: ${error.message}`);
await logAudit({
action: 'UPDATE',
table_name: 'redemption_codes',
record_id: id,
old_values: oldData,
new_values: data
});
return {
content: [{ type: 'text', text: `Code ${data.code} is now ${is_active ? 'active' : 'inactive'}` }]
};
}
);
server.tool(
'delete_code',
'Delete a redemption code',
{ id: z.string() },
async ({ id }) => {
const { data: oldData } = await supabase.from('redemption_codes').select('*').eq('id', id).single();
const { error } = await supabase.from('redemption_codes').delete().eq('id', id);
if (error) throw new Error(`Failed to delete code: ${error.message}`);
await logAudit({
action: 'DELETE',
table_name: 'redemption_codes',
record_id: id,
old_values: oldData
});
return {
content: [{ type: 'text', text: `Code ${id} deleted successfully` }]
};
}
);
server.tool(
'list_code_redemptions',
'See who redeemed a code',
{ code_id: z.string() },
async ({ code_id }) => {
const { data, error } = await supabase
.from('code_redemptions')
.select(`
*,
user:user_id (name, email)
`)
.eq('code_id', code_id)
.order('redeemed_at', { ascending: false });
if (error) throw new Error(`Failed to list redemptions: ${error.message}`);
return {
content: [{ type: 'text', text: JSON.stringify(data, null, 2) }]
};
}
);
}
+123
View File
@@ -0,0 +1,123 @@
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { z } from 'zod';
import { supabase } from '../lib/supabase.js';
import { logAudit } from '../utils/audit.js';
export function registerConfigTools(server: McpServer) {
server.tool(
'get_ai_config',
'Get AI configuration',
{},
async () => {
const { data, error } = await supabase
.from('ai_config')
.select('*')
.single();
if (error) throw new Error(`Failed to get AI config: ${error.message}`);
return {
content: [{ type: 'text', text: JSON.stringify(data, null, 2) }]
};
}
);
server.tool(
'update_ai_config',
'Update AI configuration',
{
openrouter_api_key: z.string().optional(),
default_model: z.string().optional(),
classifier_enabled: z.boolean().optional(),
classifier_model: z.string().optional(),
classifier_prompt: z.string().optional(),
council_enabled: z.boolean().optional()
},
async (updates) => {
// Get current ID (singleton table usually has 1 row, but need ID for update)
const { data: current, error: fetchError } = await supabase
.from('ai_config')
.select('*')
.single();
if (fetchError) throw new Error(`Failed to fetch current config: ${fetchError.message}`);
const { data, error } = await supabase
.from('ai_config')
.update(updates)
.eq('id', current.id)
.select()
.single();
if (error) throw new Error(`Failed to update AI config: ${error.message}`);
await logAudit({
action: 'UPDATE',
table_name: 'ai_config',
record_id: current.id,
old_values: current,
new_values: data
});
return {
content: [{ type: 'text', text: JSON.stringify(data, null, 2) }]
};
}
);
server.tool(
'get_app_config',
'Get app configuration',
{},
async () => {
const { data, error } = await supabase
.from('app_config')
.select('*')
.single();
if (error) throw new Error(`Failed to get app config: ${error.message}`);
return {
content: [{ type: 'text', text: JSON.stringify(data, null, 2) }]
};
}
);
server.tool(
'update_app_config',
'Update app configuration',
{
answer_gap_threshold: z.number().optional(),
daily_response_limit: z.number().optional()
},
async (updates) => {
const { data: current, error: fetchError } = await supabase
.from('app_config')
.select('*')
.single();
if (fetchError) throw new Error(`Failed to fetch current config: ${fetchError.message}`);
const { data, error } = await supabase
.from('app_config')
.update(updates)
.eq('id', current.id)
.select()
.single();
if (error) throw new Error(`Failed to update app config: ${error.message}`);
await logAudit({
action: 'UPDATE',
table_name: 'app_config',
record_id: current.id,
old_values: current,
new_values: data
});
return {
content: [{ type: 'text', text: JSON.stringify(data, null, 2) }]
};
}
);
}
+371
View File
@@ -0,0 +1,371 @@
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { z } from 'zod';
import { supabase } from '../lib/supabase.js';
import { logAudit } from '../utils/audit.js';
export function registerContentTools(server: McpServer) {
// --- Categories ---
server.tool(
'list_categories',
'List all categories with pack counts',
{},
async () => {
const { data, error } = await supabase
.from('categories')
.select(`
*,
question_packs (count)
`)
.order('sort_order');
if (error) throw new Error(`Failed to list categories: ${error.message}`);
return {
content: [{ type: 'text', text: JSON.stringify(data, null, 2) }]
};
}
);
server.tool(
'create_category',
'Create a new category',
{
name: z.string(),
description: z.string().optional(),
icon: z.string(),
sort_order: z.number().optional(),
is_public: z.boolean().default(false)
},
async ({ name, description, icon, sort_order, is_public }) => {
const { data, error } = await supabase
.from('categories')
.insert({ name, description, icon, sort_order: sort_order ?? 0, is_public })
.select()
.single();
if (error) throw new Error(`Failed to create category: ${error.message}`);
await logAudit({
action: 'INSERT',
table_name: 'categories',
record_id: data.id,
new_values: data
});
return {
content: [{ type: 'text', text: JSON.stringify(data, null, 2) }]
};
}
);
server.tool(
'update_category',
'Update a category',
{
id: z.string(),
name: z.string().optional(),
description: z.string().optional(),
icon: z.string().optional(),
sort_order: z.number().optional(),
is_public: z.boolean().optional()
},
async ({ id, ...updates }) => {
// Get old values for audit
const { data: oldData } = await supabase.from('categories').select('*').eq('id', id).single();
if (!oldData) throw new Error(`Category not found: ${id}`);
const { data, error } = await supabase
.from('categories')
.update(updates)
.eq('id', id)
.select()
.single();
if (error) throw new Error(`Failed to update category: ${error.message}`);
await logAudit({
action: 'UPDATE',
table_name: 'categories',
record_id: id,
old_values: oldData,
new_values: data
});
return {
content: [{ type: 'text', text: JSON.stringify(data, null, 2) }]
};
}
);
server.tool(
'delete_category',
'Delete a category',
{ id: z.string() },
async ({ id }) => {
const { data: oldData } = await supabase.from('categories').select('*').eq('id', id).single();
if (!oldData) throw new Error(`Category not found: ${id}`);
const { error } = await supabase
.from('categories')
.delete()
.eq('id', id);
if (error) throw new Error(`Failed to delete category: ${error.message}`);
await logAudit({
action: 'DELETE',
table_name: 'categories',
record_id: id,
old_values: oldData
});
return {
content: [{ type: 'text', text: `Category ${id} deleted successfully` }]
};
}
);
// --- Packs ---
server.tool(
'list_packs',
'List packs for a category or all packs',
{ category_id: z.string().optional() },
async ({ category_id }) => {
let query = supabase
.from('question_packs')
.select(`
*,
questions (count)
`)
.order('sort_order');
if (category_id) {
query = query.eq('category_id', category_id);
}
const { data, error } = await query;
if (error) throw new Error(`Failed to list packs: ${error.message}`);
return {
content: [{ type: 'text', text: JSON.stringify(data, null, 2) }]
};
}
);
server.tool(
'create_pack',
'Create a new question pack',
{
name: z.string(),
category_id: z.string(),
description: z.string().optional(),
is_premium: z.boolean().default(false),
is_public: z.boolean().default(false),
is_explicit: z.boolean().default(false),
sort_order: z.number().optional()
},
async (input) => {
const { data, error } = await supabase
.from('question_packs')
.insert({ ...input, sort_order: input.sort_order ?? 0 })
.select()
.single();
if (error) throw new Error(`Failed to create pack: ${error.message}`);
await logAudit({
action: 'INSERT',
table_name: 'question_packs',
record_id: data.id,
new_values: data
});
return {
content: [{ type: 'text', text: JSON.stringify(data, null, 2) }]
};
}
);
server.tool(
'update_pack',
'Update a question pack',
{
id: z.string(),
name: z.string().optional(),
category_id: z.string().optional(),
description: z.string().optional(),
is_premium: z.boolean().optional(),
is_public: z.boolean().optional(),
is_explicit: z.boolean().optional(),
sort_order: z.number().optional()
},
async ({ id, ...updates }) => {
const { data: oldData } = await supabase.from('question_packs').select('*').eq('id', id).single();
if (!oldData) throw new Error(`Pack not found: ${id}`);
const { data, error } = await supabase
.from('question_packs')
.update(updates)
.eq('id', id)
.select()
.single();
if (error) throw new Error(`Failed to update pack: ${error.message}`);
await logAudit({
action: 'UPDATE',
table_name: 'question_packs',
record_id: id,
old_values: oldData,
new_values: data
});
return {
content: [{ type: 'text', text: JSON.stringify(data, null, 2) }]
};
}
);
server.tool(
'delete_pack',
'Delete a question pack',
{ id: z.string() },
async ({ id }) => {
const { data: oldData } = await supabase.from('question_packs').select('*').eq('id', id).single();
if (!oldData) throw new Error(`Pack not found: ${id}`);
const { error } = await supabase.from('question_packs').delete().eq('id', id);
if (error) throw new Error(`Failed to delete pack: ${error.message}`);
await logAudit({
action: 'DELETE',
table_name: 'question_packs',
record_id: id,
old_values: oldData
});
return {
content: [{ type: 'text', text: `Pack ${id} deleted successfully` }]
};
}
);
// --- Questions ---
server.tool(
'list_questions',
'List questions for a pack',
{ pack_id: z.string() },
async ({ pack_id }) => {
const { data, error } = await supabase
.from('questions')
.select('*')
.eq('pack_id', pack_id)
.order('created_at'); // Questions don't strictly have a sort_order usually
if (error) throw new Error(`Failed to list questions: ${error.message}`);
return {
content: [{ type: 'text', text: JSON.stringify(data, null, 2) }]
};
}
);
server.tool(
'create_question',
'Create a single question',
{
pack_id: z.string(),
text: z.string(),
partner_text: z.string().optional(),
intensity: z.number().min(1).max(5).default(1)
},
async (input) => {
const { data, error } = await supabase
.from('questions')
.insert(input)
.select()
.single();
if (error) throw new Error(`Failed to create question: ${error.message}`);
await logAudit({
action: 'INSERT',
table_name: 'questions',
record_id: data.id,
new_values: data
});
return {
content: [{ type: 'text', text: JSON.stringify(data, null, 2) }]
};
}
);
server.tool(
'update_question',
'Update a question',
{
id: z.string(),
text: z.string().optional(),
partner_text: z.string().optional(),
intensity: z.number().min(1).max(5).optional(),
pack_id: z.string().optional()
},
async ({ id, ...updates }) => {
const { data: oldData } = await supabase.from('questions').select('*').eq('id', id).single();
if (!oldData) throw new Error(`Question not found: ${id}`);
const { data, error } = await supabase
.from('questions')
.update(updates)
.eq('id', id)
.select()
.single();
if (error) throw new Error(`Failed to update question: ${error.message}`);
await logAudit({
action: 'UPDATE',
table_name: 'questions',
record_id: id,
old_values: oldData,
new_values: data
});
return {
content: [{ type: 'text', text: JSON.stringify(data, null, 2) }]
};
}
);
server.tool(
'delete_questions',
'Delete one or more questions',
{ ids: z.array(z.string()) },
async ({ ids }) => {
const { data: oldData } = await supabase.from('questions').select('*').in('id', ids);
const { error } = await supabase.from('questions').delete().in('id', ids);
if (error) throw new Error(`Failed to delete questions: ${error.message}`);
// Log audit for each deleted question
if (oldData) {
for (const record of oldData) {
await logAudit({
action: 'DELETE',
table_name: 'questions',
record_id: record.id,
old_values: record
});
}
}
return {
content: [{ type: 'text', text: `${ids.length} questions deleted successfully` }]
};
}
);
}
+124
View File
@@ -0,0 +1,124 @@
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { z } from 'zod';
import { supabase } from '../lib/supabase.js';
import { logAudit } from '../utils/audit.js';
export function registerFeedbackTools(server: McpServer) {
server.tool(
'list_feedback',
'List feedback with filters',
{
type: z.enum(['bug', 'feature_request', 'general', 'question']).optional(),
status: z.enum(['new', 'reviewed', 'in_progress', 'resolved', 'closed']).optional(),
limit: z.number().default(20),
offset: z.number().default(0)
},
async ({ type, status, limit, offset }) => {
let query = supabase
.from('feedback')
.select(`
*,
user:user_id (name, email)
`, { count: 'exact' });
if (type) query = query.eq('type', type);
if (status) query = query.eq('status', status);
const { data, error, count } = await query
.order('created_at', { ascending: false })
.range(offset, offset + limit - 1);
if (error) throw new Error(`Failed to list feedback: ${error.message}`);
return {
content: [{ type: 'text', text: JSON.stringify({ data, count }, null, 2) }]
};
}
);
server.tool(
'get_feedback_detail',
'Get full feedback details',
{ id: z.string() },
async ({ id }) => {
const { data, error } = await supabase
.from('feedback')
.select(`
*,
user:user_id (name, email),
question:question_id (text)
`)
.eq('id', id)
.single();
if (error) throw new Error(`Failed to get feedback detail: ${error.message}`);
return {
content: [{ type: 'text', text: JSON.stringify(data, null, 2) }]
};
}
);
server.tool(
'update_feedback_status',
'Update feedback status',
{
id: z.string(),
status: z.enum(['new', 'reviewed', 'in_progress', 'resolved', 'closed'])
},
async ({ id, status }) => {
const { data: oldData } = await supabase.from('feedback').select('*').eq('id', id).single();
const { data, error } = await supabase
.from('feedback')
.update({ status })
.eq('id', id)
.select()
.single();
if (error) throw new Error(`Failed to update status: ${error.message}`);
await logAudit({
action: 'UPDATE',
table_name: 'feedback',
record_id: id,
old_values: oldData,
new_values: data
});
return {
content: [{ type: 'text', text: `Feedback ${id} status updated to ${status}` }]
};
}
);
server.tool(
'add_feedback_notes',
'Add admin notes to feedback',
{ id: z.string(), notes: z.string() },
async ({ id, notes }) => {
const { data: oldData } = await supabase.from('feedback').select('*').eq('id', id).single();
const { data, error } = await supabase
.from('feedback')
.update({ admin_notes: notes })
.eq('id', id)
.select()
.single();
if (error) throw new Error(`Failed to update notes: ${error.message}`);
await logAudit({
action: 'UPDATE',
table_name: 'feedback',
record_id: id,
old_values: oldData,
new_values: data
});
return {
content: [{ type: 'text', text: `Notes added to feedback ${id}` }]
};
}
);
}
+147
View File
@@ -0,0 +1,147 @@
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { z } from 'zod';
import { supabase } from '../lib/supabase.js';
import { logAudit } from '../utils/audit.js';
export function registerModerationTools(server: McpServer) {
server.tool(
'list_flagged_messages',
'List messages flagged by AI for review',
{
limit: z.number().default(20),
offset: z.number().default(0)
},
async ({ limit, offset }) => {
const { data, error, count } = await supabase
.from('messages')
.select('*', { count: 'exact' })
.eq('moderation_status', 'flagged')
.order('created_at', { ascending: false })
.range(offset, offset + limit - 1);
if (error) throw new Error(`Failed to list flagged messages: ${error.message}`);
return {
content: [{ type: 'text', text: JSON.stringify({ data, count }, null, 2) }]
};
}
);
server.tool(
'list_message_reports',
'List user-submitted reports',
{
status: z.enum(['pending', 'reviewed', 'dismissed']).default('pending'),
limit: z.number().default(20),
offset: z.number().default(0)
},
async ({ status, limit, offset }) => {
const { data, error, count } = await supabase
.from('message_reports')
.select(`
*,
message:message_id (*),
reporter:reporter_id (name, email)
`, { count: 'exact' })
.eq('status', status)
.order('created_at', { ascending: false })
.range(offset, offset + limit - 1);
if (error) throw new Error(`Failed to list reports: ${error.message}`);
return {
content: [{ type: 'text', text: JSON.stringify({ data, count }, null, 2) }]
};
}
);
server.tool(
'mark_message_safe',
'Mark a flagged message as safe',
{ message_id: z.string() },
async ({ message_id }) => {
const { data: oldData } = await supabase.from('messages').select('*').eq('id', message_id).single();
const { data, error } = await supabase
.from('messages')
.update({ moderation_status: 'safe' })
.eq('id', message_id)
.select()
.single();
if (error) throw new Error(`Failed to update message: ${error.message}`);
await logAudit({
action: 'UPDATE',
table_name: 'messages',
record_id: message_id,
old_values: oldData,
new_values: data
});
return {
content: [{ type: 'text', text: `Message ${message_id} marked as safe` }]
};
}
);
server.tool(
'dismiss_report',
'Dismiss a user report',
{ report_id: z.string() },
async ({ report_id }) => {
const { data: oldData } = await supabase.from('message_reports').select('*').eq('id', report_id).single();
const { data, error } = await supabase
.from('message_reports')
.update({ status: 'dismissed', reviewed_at: new Date().toISOString() })
.eq('id', report_id)
.select()
.single();
if (error) throw new Error(`Failed to dismiss report: ${error.message}`);
await logAudit({
action: 'UPDATE',
table_name: 'message_reports',
record_id: report_id,
old_values: oldData,
new_values: data
});
return {
content: [{ type: 'text', text: `Report ${report_id} dismissed` }]
};
}
);
server.tool(
'mark_report_reviewed',
'Mark a report as reviewed (action taken)',
{ report_id: z.string() },
async ({ report_id }) => {
const { data: oldData } = await supabase.from('message_reports').select('*').eq('id', report_id).single();
const { data, error } = await supabase
.from('message_reports')
.update({ status: 'reviewed', reviewed_at: new Date().toISOString() })
.eq('id', report_id)
.select()
.single();
if (error) throw new Error(`Failed to update report: ${error.message}`);
await logAudit({
action: 'UPDATE',
table_name: 'message_reports',
record_id: report_id,
old_values: oldData,
new_values: data
});
return {
content: [{ type: 'text', text: `Report ${report_id} marked as reviewed` }]
};
}
);
}
+217
View File
@@ -0,0 +1,217 @@
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { z } from 'zod';
import { supabase } from '../lib/supabase.js';
import { logAudit } from '../utils/audit.js';
export function registerUserTools(server: McpServer) {
server.tool(
'list_users',
'List users with pagination and search',
{
page: z.number().default(1),
limit: z.number().default(20),
search: z.string().optional()
},
async ({ page, limit, search }) => {
const offset = (page - 1) * limit;
let query = supabase
.from('profiles')
.select('*', { count: 'exact' });
if (search) {
query = query.or(`name.ilike.%${search}%,email.ilike.%${search}%`);
}
const { data, error, count } = await query
.range(offset, offset + limit - 1)
.order('created_at', { ascending: false });
if (error) throw new Error(`Failed to list users: ${error.message}`);
return {
content: [{ type: 'text', text: JSON.stringify({ data, count, page, limit }, null, 2) }]
};
}
);
server.tool(
'get_user_detail',
'Get full user profile details',
{ user_id: z.string() },
async ({ user_id }) => {
const { data, error } = await supabase
.from('profiles')
.select(`
*,
couple:couple_id (*)
`)
.eq('id', user_id)
.single();
if (error) throw new Error(`Failed to get user details: ${error.message}`);
return {
content: [{ type: 'text', text: JSON.stringify(data, null, 2) }]
};
}
);
server.tool(
'get_user_responses',
'Get user question responses',
{ user_id: z.string(), limit: z.number().default(50) },
async ({ user_id, limit }) => {
const { data, error } = await supabase
.from('responses')
.select(`
*,
question:question_id (text, intensity)
`)
.eq('user_id', user_id)
.order('created_at', { ascending: false })
.limit(limit);
if (error) throw new Error(`Failed to get responses: ${error.message}`);
return {
content: [{ type: 'text', text: JSON.stringify(data, null, 2) }]
};
}
);
server.tool(
'get_user_matches',
'Get user matches',
{ user_id: z.string() },
async ({ user_id }) => {
// First get couple_id
const { data: profile } = await supabase.from('profiles').select('couple_id').eq('id', user_id).single();
if (!profile?.couple_id) {
return { content: [{ type: 'text', text: 'User is not in a couple' }] };
}
const { data, error } = await supabase
.from('matches')
.select(`
*,
question:question_id (text)
`)
.eq('couple_id', profile.couple_id)
.order('created_at', { ascending: false });
if (error) throw new Error(`Failed to get matches: ${error.message}`);
return {
content: [{ type: 'text', text: JSON.stringify(data, null, 2) }]
};
}
);
server.tool(
'get_user_media',
'List user uploaded media',
{ user_id: z.string() },
async ({ user_id }) => {
// This is tricky as media is in storage, but usually referenced in messages
// We'll search messages table for media_path where user_id matches
const { data, error } = await supabase
.from('messages')
.select('id, media_path, created_at, match_id')
.eq('user_id', user_id)
.not('media_path', 'is', null)
.order('created_at', { ascending: false });
if (error) throw new Error(`Failed to get user media: ${error.message}`);
// Generate public URLs for media
const mediaWithUrls = data.map(m => {
const { data: urlData } = supabase.storage
.from('chat-media')
.getPublicUrl(m.media_path);
return {
...m,
url: urlData.publicUrl
};
});
return {
content: [{ type: 'text', text: JSON.stringify(mediaWithUrls, null, 2) }]
};
}
);
server.tool(
'get_match_chat',
'View chat messages for a match',
{ match_id: z.string(), limit: z.number().default(50) },
async ({ match_id, limit }) => {
const { data, error } = await supabase
.from('messages')
.select('*')
.eq('match_id', match_id)
.order('created_at', { ascending: false }) // Newest first
.limit(limit);
if (error) throw new Error(`Failed to get chat: ${error.message}`);
// Reverse to show chronological order in output if desired, but JSON is fine
return {
content: [{ type: 'text', text: JSON.stringify(data, null, 2) }]
};
}
);
server.tool(
'gift_premium',
'Give user premium access',
{
user_id: z.string(),
days: z.number().default(30),
reason: z.string().optional() // For audit log
},
async ({ user_id, days }) => {
const expiresAt = new Date();
expiresAt.setDate(expiresAt.getDate() + days);
const { data: oldData } = await supabase.from('profiles').select('is_premium').eq('id', user_id).single();
// We update profiles table directly or insert into subscriptions?
// Research said `profiles.is_premium` is the flag, but also mentioned `subscriptions` table.
// Usually `subscriptions` drives `is_premium` via webhooks, but for gifting we might just set the flag
// OR insert a manual subscription.
// The admin dashboard uses `auditedSupabase.insert('subscriptions', {...})`.
const { data, error } = await supabase
.from('subscriptions')
.insert({
user_id,
product_id: 'admin_gift',
status: 'active',
expires_at: expiresAt.toISOString(),
is_sandbox: false
})
.select()
.single();
if (error) throw new Error(`Failed to gift premium: ${error.message}`);
// Also ensure profile is updated (if not handled by triggers)
await supabase.from('profiles').update({ is_premium: true }).eq('id', user_id);
await logAudit({
action: 'INSERT',
table_name: 'subscriptions',
record_id: data.id,
new_values: data,
old_values: { reason: 'Premium Gift', old_is_premium: oldData?.is_premium }
});
return {
content: [{ type: 'text', text: `Premium gifted to user ${user_id} until ${expiresAt.toISOString()}` }]
};
}
);
}
+81
View File
@@ -0,0 +1,81 @@
import { supabase } from '../lib/supabase.js';
export interface AuditLogEntry {
action: 'INSERT' | 'UPDATE' | 'DELETE';
table_name: string;
record_id?: string;
old_values?: Record<string, any>;
new_values?: Record<string, any>;
admin_user_id?: string;
}
let cachedSystemAdminId: string | null = null;
async function getSystemAdminId(): Promise<string | null> {
if (cachedSystemAdminId) return cachedSystemAdminId;
// Try to find a super_admin user
const { data, error } = await supabase
.from('admin_users')
.select('user_id')
.eq('role', 'super_admin')
.limit(1)
.maybeSingle();
if (data?.user_id) {
cachedSystemAdminId = data.user_id;
return data.user_id;
}
// Fallback: any admin user
const { data: anyAdmin } = await supabase
.from('admin_users')
.select('user_id')
.limit(1)
.maybeSingle();
if (anyAdmin?.user_id) {
cachedSystemAdminId = anyAdmin.user_id;
return anyAdmin.user_id;
}
return null;
}
/**
* Log an admin action to the audit_logs table
*/
export async function logAudit(entry: AuditLogEntry) {
try {
let adminUserId = entry.admin_user_id;
if (!adminUserId) {
adminUserId = await getSystemAdminId() || undefined;
}
if (!adminUserId) {
console.warn('Cannot log audit entry: No admin user found to attribute action to');
return;
}
// Ensure values are plain objects, not Supabase response objects
const cleanOldValues = entry.old_values ? JSON.parse(JSON.stringify(entry.old_values)) : null;
const cleanNewValues = entry.new_values ? JSON.parse(JSON.stringify(entry.new_values)) : null;
const { error } = await supabase.from('audit_logs').insert({
action: entry.action,
table_name: entry.table_name,
record_id: entry.record_id || '00000000-0000-0000-0000-000000000000', // Fallback UUID if needed
old_values: cleanOldValues,
new_values: cleanNewValues,
admin_role: 'super_admin', // MCP acts as super admin
admin_user_id: adminUserId
});
if (error) {
console.error('Failed to write audit log:', error);
}
} catch (err) {
console.error('Error in logAudit:', err);
}
}
+12
View File
@@ -0,0 +1,12 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "Bundler",
"strict": true,
"skipLibCheck": true,
"esModuleInterop": true,
"outDir": "dist"
},
"include": ["src/**/*"]
}
+5
View File
@@ -149,6 +149,11 @@ export default function MatchesScreen() {
}, [matches.length]);
const renderItem = ({ item, index }: { item: any; index: number }) => {
// Skip rendering if question was deleted
if (!item.question) {
return null;
}
const userResponse = item.responses?.find((r: any) => r.user_id === user?.id);
const partnerResponse = item.responses?.find((r: any) => r.user_id !== user?.id);
+26 -12
View File
@@ -42,15 +42,15 @@ const getIntensityDisplay = (pack: QuestionPack) => {
const min = isValidIntensity(pack.min_intensity) ? pack.min_intensity : null;
const max = isValidIntensity(pack.max_intensity) ? pack.max_intensity : null;
const avg = typeof pack.avg_intensity === "number" ? clampIntensity(pack.avg_intensity) : null;
const fallback = avg ?? (pack.is_explicit ? 5 : 2);
const flameCount = max ?? avg ?? (pack.is_explicit ? 5 : 2);
const label = min && max
? min === max
? INTENSITY_LABELS[min]
: `${INTENSITY_LABELS[min]} - ${INTENSITY_LABELS[max]}`
: INTENSITY_LABELS[flameCount];
const minCount = min ?? max ?? fallback;
const maxCount = max ?? min ?? fallback;
const label = minCount === maxCount
? INTENSITY_LABELS[minCount]
: `${INTENSITY_LABELS[minCount]} - ${INTENSITY_LABELS[maxCount]}`;
return { flameCount, label };
return { minCount, maxCount, label };
};
@@ -100,14 +100,19 @@ function PackRow({ pack, isEnabled, isPremiumLocked, isExpanded, onToggle, onPre
</Text>
<View style={styles.packIntensityRow}>
<View style={styles.packIntensityFlames}>
{Array.from({ length: 5 }).map((_, index) => (
{Array.from({ length: 5 }).map((_, index) => {
const level = index + 1;
const isInRange = level >= intensityDisplay.minCount && level <= intensityDisplay.maxCount;
return (
<Ionicons
key={`intensity-${pack.id}-${index}`}
name={index < intensityDisplay.flameCount ? "flame" : "flame-outline"}
name={isInRange ? "flame" : "flame-outline"}
size={12}
color={index < intensityDisplay.flameCount ? colors.primary : colors.textTertiary}
color={isInRange ? colors.primary : colors.textTertiary}
/>
))}
);
})}
</View>
<Text style={styles.packIntensityText}>{intensityDisplay.label}</Text>
</View>
@@ -462,7 +467,15 @@ export default function QuestionPacksScreen() {
{(packsByCategory.get("uncategorized")?.length || 0) > 0 && (
<CategorySection
category={{ id: "uncategorized", name: "Other", description: null, icon: "📁", sort_order: 999, created_at: "" }}
category={{
id: "uncategorized",
name: "Other",
description: null,
icon: "📁",
sort_order: 999,
created_at: "",
is_public: true
}}
packs={packsByCategory.get("uncategorized") || []}
enabledPackIds={enabledPackIds}
hasPremiumAccess={hasPremiumAccess}
@@ -727,6 +740,7 @@ const styles = StyleSheet.create({
},
packIntensityFlames: {
flexDirection: "row",
alignItems: "center",
gap: 2,
},
packIntensityText: {
+1 -1
View File
@@ -71,7 +71,7 @@ export default function PackDetailsScreen() {
try {
const { data, error } = await supabase
.from("questions")
.select("*")
.select("id, pack_id, text, partner_text, intensity, allowed_couple_genders, target_user_genders, created_at")
.eq("pack_id", id)
.order("created_at");
@@ -26,11 +26,11 @@ export interface IntensitySliderProps {
}
const INTENSITY_LEVELS: Array<{ level: IntensityLevel; label: string; emoji: string; description: string }> = [
{ level: 1, label: 'Gentle', emoji: '💭', description: 'Conversations & emotional connection' },
{ level: 2, label: 'Warm', emoji: '💕', description: 'Cuddling, kisses & flirty moments' },
{ level: 3, label: 'Playful', emoji: '😏', description: 'Making out & suggestive content' },
{ level: 4, label: 'Steamy', emoji: '🔥', description: 'Intimate activities & explicit content' },
{ level: 5, label: 'Intense', emoji: '🌶️', description: 'Advanced intimacy & fantasies' },
{ level: 1, label: 'Gentle', emoji: '💭', description: 'Pure emotional connection & non-sexual bonding' },
{ level: 2, label: 'Warm', emoji: '💕', description: 'Romantic atmosphere & affectionate touch' },
{ level: 3, label: 'Playful', emoji: '😏', description: 'Light sexual exploration & sensual discovery' },
{ level: 4, label: 'Steamy', emoji: '🔥', description: 'Explicit sexual activities & moderate adventure' },
{ level: 5, label: 'Intense', emoji: '🌶️', description: 'Advanced/BDSM/Extreme exploration' },
];
const HEAT_COLORS = [
@@ -55,7 +55,9 @@ export function ProfileScreen() {
const [showFeedbackModal, setShowFeedbackModal] = React.useState(false);
// Derived formatting
const isOwnSubscription = user?.is_premium && subscription.isProUser;
// Use OR: if RevenueCat says they're subscribed OR database says premium, it's their own
// This handles the race condition after purchase (RevenueCat updates before webhook syncs DB)
const isOwnSubscription = subscription.isProUser || user?.is_premium;
const hasPremiumAccess = user?.is_premium || partner?.is_premium || subscription.isProUser;
const formatExpirationDate = (date: Date | null | string) => {
+14 -3
View File
@@ -51,6 +51,10 @@ export const usePacksStore = create<PacksState>((set, get) => ({
.select("*")
.order("sort_order");
// Hide categories explicitly marked non-public, but keep null for compatibility.
const visibleCategories = (categories ?? []).filter(category => category.is_public !== false);
const visibleCategoryIds = new Set(visibleCategories.map(category => category.id));
// Fetch packs with category info and question count
let query = supabase
.from("question_packs")
@@ -63,15 +67,22 @@ export const usePacksStore = create<PacksState>((set, get) => ({
query = query.or(`max_intensity.is.null,max_intensity.lte.${maxIntensity}`);
}
const { data: packs } = await query;
const visiblePacks = (packs ?? []).filter(pack => {
if (pack.category?.is_public === false) return false;
if (pack.category_id && visibleCategoryIds.size > 0 && !visibleCategoryIds.has(pack.category_id)) {
return false;
}
return true;
});
// Also fetch enabled packs if logged in
await get().fetchEnabledPacks();
set({
packs: packs ?? [],
categories: categories ?? [],
packs: visiblePacks,
categories: visibleCategories,
isLoading: false
});
},
+3 -3
View File
@@ -93,7 +93,7 @@ export const useResponsesStore = create<ResponsesState>((set, get) => ({
if (refresh) {
const { count, error: countError } = await supabase
.from("responses")
.select("*", { count: "exact", head: true })
.select("*, question:questions!inner(pack:question_packs!inner(id))", { count: "exact", head: true })
.eq("user_id", userId);
if (!countError) {
@@ -109,13 +109,13 @@ export const useResponsesStore = create<ResponsesState>((set, get) => ({
question_id,
answer,
created_at,
question:questions(
question:questions!inner(
id,
text,
partner_text,
intensity,
pack_id,
pack:question_packs(id, name, icon)
pack:question_packs!inner(id, name, icon)
)
`)
.eq("user_id", userId)
+4
View File
@@ -35,8 +35,10 @@ export interface Category {
icon: string | null;
sort_order: number;
created_at: string;
is_public: boolean;
}
// Question pack
export interface QuestionPack {
id: string;
@@ -65,6 +67,8 @@ export interface Question {
intensity: 1 | 2 | 3 | 4 | 5;
allowed_couple_genders?: string[] | null;
target_user_genders?: string[] | null;
/** References the primary question that this is an inverse of (e.g., give vs receive). NULL means this is a primary/standalone question. */
inverse_of?: string | null;
created_at: string;
}
+385 -16
View File
@@ -47,49 +47,106 @@ export type Database = {
ai_config: {
Row: {
cherry_pick_ensure_intensity_distribution: boolean | null
classifier_enabled: boolean | null
classifier_model: string | null
classifier_prompt: string | null
classifier_temperature: number | null
council_enabled: boolean | null
council_generator_model: string | null
council_generators: Json | null
council_reviewer_model: string | null
council_reviewer_temperature: number | null
council_selection_mode: string | null
default_model: string | null
default_temperature: number | null
heuristic_keyword_triggers: string | null
heuristic_min_text_length: number
heuristic_record_reason: boolean
heuristic_skip_if_no_alnum: boolean
heuristic_skip_media_without_text: boolean
heuristic_use_default_keywords: boolean
heuristic_use_default_whitelist: boolean
heuristic_whitelist: string | null
heuristic_whitelist_max_length: number
heuristics_enabled: boolean
id: string
model_fix: string | null
model_generate: string | null
model_polish: string | null
openrouter_api_key: string | null
temperature_fix: number | null
temperature_generate: number | null
temperature_polish: number | null
updated_at: string | null
updated_by: string | null
}
Insert: {
cherry_pick_ensure_intensity_distribution?: boolean | null
classifier_enabled?: boolean | null
classifier_model?: string | null
classifier_prompt?: string | null
classifier_temperature?: number | null
council_enabled?: boolean | null
council_generator_model?: string | null
council_generators?: Json | null
council_reviewer_model?: string | null
council_reviewer_temperature?: number | null
council_selection_mode?: string | null
default_model?: string | null
default_temperature?: number | null
heuristic_keyword_triggers?: string | null
heuristic_min_text_length?: number
heuristic_record_reason?: boolean
heuristic_skip_if_no_alnum?: boolean
heuristic_skip_media_without_text?: boolean
heuristic_use_default_keywords?: boolean
heuristic_use_default_whitelist?: boolean
heuristic_whitelist?: string | null
heuristic_whitelist_max_length?: number
heuristics_enabled?: boolean
id?: string
model_fix?: string | null
model_generate?: string | null
model_polish?: string | null
openrouter_api_key?: string | null
temperature_fix?: number | null
temperature_generate?: number | null
temperature_polish?: number | null
updated_at?: string | null
updated_by?: string | null
}
Update: {
cherry_pick_ensure_intensity_distribution?: boolean | null
classifier_enabled?: boolean | null
classifier_model?: string | null
classifier_prompt?: string | null
classifier_temperature?: number | null
council_enabled?: boolean | null
council_generator_model?: string | null
council_generators?: Json | null
council_reviewer_model?: string | null
council_reviewer_temperature?: number | null
council_selection_mode?: string | null
default_model?: string | null
default_temperature?: number | null
heuristic_keyword_triggers?: string | null
heuristic_min_text_length?: number
heuristic_record_reason?: boolean
heuristic_skip_if_no_alnum?: boolean
heuristic_skip_media_without_text?: boolean
heuristic_use_default_keywords?: boolean
heuristic_use_default_whitelist?: boolean
heuristic_whitelist?: string | null
heuristic_whitelist_max_length?: number
heuristics_enabled?: boolean
id?: string
model_fix?: string | null
model_generate?: string | null
model_polish?: string | null
openrouter_api_key?: string | null
temperature_fix?: number | null
temperature_generate?: number | null
temperature_polish?: number | null
updated_at?: string | null
updated_by?: string | null
}
@@ -124,6 +181,7 @@ export type Database = {
action: Database["public"]["Enums"]["audit_action"]
admin_role: Database["public"]["Enums"]["admin_role"]
admin_user_id: string
admin_username: string | null
changed_fields: string[] | null
created_at: string | null
id: string
@@ -136,6 +194,7 @@ export type Database = {
action: Database["public"]["Enums"]["audit_action"]
admin_role: Database["public"]["Enums"]["admin_role"]
admin_user_id: string
admin_username?: string | null
changed_fields?: string[] | null
created_at?: string | null
id?: string
@@ -148,6 +207,7 @@ export type Database = {
action?: Database["public"]["Enums"]["audit_action"]
admin_role?: Database["public"]["Enums"]["admin_role"]
admin_user_id?: string
admin_username?: string | null
changed_fields?: string[] | null
created_at?: string | null
id?: string
@@ -164,6 +224,7 @@ export type Database = {
description: string | null
icon: string | null
id: string
is_public: boolean
name: string
sort_order: number | null
}
@@ -172,6 +233,7 @@ export type Database = {
description?: string | null
icon?: string | null
id?: string
is_public?: boolean
name: string
sort_order?: number | null
}
@@ -180,6 +242,7 @@ export type Database = {
description?: string | null
icon?: string | null
id?: string
is_public?: boolean
name?: string
sort_order?: number | null
}
@@ -268,6 +331,139 @@ export type Database = {
}
Relationships: []
}
dare_messages: {
Row: {
content: string
created_at: string
id: string
read_at: string | null
sender_id: string
sent_dare_id: string
}
Insert: {
content: string
created_at?: string
id?: string
read_at?: string | null
sender_id: string
sent_dare_id: string
}
Update: {
content?: string
created_at?: string
id?: string
read_at?: string | null
sender_id?: string
sent_dare_id?: string
}
Relationships: [
{
foreignKeyName: "dare_messages_sender_id_fkey"
columns: ["sender_id"]
isOneToOne: false
referencedRelation: "profiles"
referencedColumns: ["id"]
},
{
foreignKeyName: "dare_messages_sent_dare_id_fkey"
columns: ["sent_dare_id"]
isOneToOne: false
referencedRelation: "sent_dares"
referencedColumns: ["id"]
},
]
}
dare_packs: {
Row: {
avg_intensity: number | null
category_id: string | null
created_at: string
description: string | null
icon: string | null
id: string
is_explicit: boolean
is_premium: boolean
is_public: boolean
max_intensity: number | null
min_intensity: number | null
name: string
sort_order: number
}
Insert: {
avg_intensity?: number | null
category_id?: string | null
created_at?: string
description?: string | null
icon?: string | null
id?: string
is_explicit?: boolean
is_premium?: boolean
is_public?: boolean
max_intensity?: number | null
min_intensity?: number | null
name: string
sort_order?: number
}
Update: {
avg_intensity?: number | null
category_id?: string | null
created_at?: string
description?: string | null
icon?: string | null
id?: string
is_explicit?: boolean
is_premium?: boolean
is_public?: boolean
max_intensity?: number | null
min_intensity?: number | null
name?: string
sort_order?: number
}
Relationships: [
{
foreignKeyName: "dare_packs_category_id_fkey"
columns: ["category_id"]
isOneToOne: false
referencedRelation: "categories"
referencedColumns: ["id"]
},
]
}
dares: {
Row: {
created_at: string
id: string
intensity: number
pack_id: string
suggested_duration_hours: number | null
text: string
}
Insert: {
created_at?: string
id?: string
intensity?: number
pack_id: string
suggested_duration_hours?: number | null
text: string
}
Update: {
created_at?: string
id?: string
intensity?: number
pack_id?: string
suggested_duration_hours?: number | null
text?: string
}
Relationships: [
{
foreignKeyName: "dares_pack_id_fkey"
columns: ["pack_id"]
isOneToOne: false
referencedRelation: "dare_packs"
referencedColumns: ["id"]
},
]
}
feature_interests: {
Row: {
created_at: string
@@ -378,22 +574,22 @@ export type Database = {
}
match_archives: {
Row: {
archived_at: string | null
id: string
match_id: string
user_id: string
archived_at: string | null
}
Insert: {
archived_at?: string | null
id?: string
match_id: string
user_id: string
archived_at?: string | null
}
Update: {
archived_at?: string | null
id?: string
match_id?: string
user_id?: string
archived_at?: string | null
}
Relationships: [
{
@@ -403,13 +599,6 @@ export type Database = {
referencedRelation: "matches"
referencedColumns: ["id"]
},
{
foreignKeyName: "match_archives_user_id_fkey"
columns: ["user_id"]
isOneToOne: false
referencedRelation: "users"
referencedColumns: ["id"]
},
]
}
matches: {
@@ -456,22 +645,22 @@ export type Database = {
}
message_deletions: {
Row: {
deleted_at: string | null
id: string
message_id: string
user_id: string
deleted_at: string | null
}
Insert: {
deleted_at?: string | null
id?: string
message_id: string
user_id: string
deleted_at?: string | null
}
Update: {
deleted_at?: string | null
id?: string
message_id?: string
user_id?: string
deleted_at?: string | null
}
Relationships: [
{
@@ -481,23 +670,66 @@ export type Database = {
referencedRelation: "messages"
referencedColumns: ["id"]
},
]
}
message_reports: {
Row: {
created_at: string | null
id: string
message_id: string
reason: Database["public"]["Enums"]["report_reason"]
reporter_id: string
reviewed_at: string | null
reviewed_by: string | null
status: string
}
Insert: {
created_at?: string | null
id?: string
message_id: string
reason: Database["public"]["Enums"]["report_reason"]
reporter_id: string
reviewed_at?: string | null
reviewed_by?: string | null
status?: string
}
Update: {
created_at?: string | null
id?: string
message_id?: string
reason?: Database["public"]["Enums"]["report_reason"]
reporter_id?: string
reviewed_at?: string | null
reviewed_by?: string | null
status?: string
}
Relationships: [
{
foreignKeyName: "message_deletions_user_id_fkey"
columns: ["user_id"]
foreignKeyName: "message_reports_message_id_fkey"
columns: ["message_id"]
isOneToOne: false
referencedRelation: "users"
referencedRelation: "messages"
referencedColumns: ["id"]
},
{
foreignKeyName: "message_reports_reporter_profile_fk"
columns: ["reporter_id"]
isOneToOne: false
referencedRelation: "profiles"
referencedColumns: ["id"]
},
]
}
messages: {
Row: {
category: string | null
content: string | null
created_at: string | null
deleted_at: string | null
delivered_at: string | null
encrypted_content: string | null
encryption_iv: string | null
flag_reason: string | null
id: string
keys_metadata: Json | null
match_id: string
@@ -506,17 +738,20 @@ export type Database = {
media_path: string | null
media_type: string | null
media_viewed_at: string | null
moderation_status: string | null
read_at: string | null
user_id: string
version: number | null
}
Insert: {
category?: string | null
content?: string | null
created_at?: string | null
deleted_at?: string | null
delivered_at?: string | null
encrypted_content?: string | null
encryption_iv?: string | null
flag_reason?: string | null
id?: string
keys_metadata?: Json | null
match_id: string
@@ -525,17 +760,20 @@ export type Database = {
media_path?: string | null
media_type?: string | null
media_viewed_at?: string | null
moderation_status?: string | null
read_at?: string | null
user_id: string
version?: number | null
}
Update: {
category?: string | null
content?: string | null
created_at?: string | null
deleted_at?: string | null
delivered_at?: string | null
encrypted_content?: string | null
encryption_iv?: string | null
flag_reason?: string | null
id?: string
keys_metadata?: Json | null
match_id?: string
@@ -544,6 +782,7 @@ export type Database = {
media_path?: string | null
media_type?: string | null
media_viewed_at?: string | null
moderation_status?: string | null
read_at?: string | null
user_id?: string
version?: number | null
@@ -597,6 +836,7 @@ export type Database = {
gender: string | null
id: string
is_premium: boolean | null
max_intensity: number | null
name: string | null
onboarding_completed: boolean | null
public_key_jwk: Json | null
@@ -613,6 +853,7 @@ export type Database = {
gender?: string | null
id: string
is_premium?: boolean | null
max_intensity?: number | null
name?: string | null
onboarding_completed?: boolean | null
public_key_jwk?: Json | null
@@ -629,6 +870,7 @@ export type Database = {
gender?: string | null
id?: string
is_premium?: boolean | null
max_intensity?: number | null
name?: string | null
onboarding_completed?: boolean | null
public_key_jwk?: Json | null
@@ -649,6 +891,7 @@ export type Database = {
}
question_packs: {
Row: {
avg_intensity: number | null
category_id: string | null
created_at: string | null
description: string | null
@@ -657,10 +900,13 @@ export type Database = {
is_explicit: boolean
is_premium: boolean | null
is_public: boolean | null
max_intensity: number | null
min_intensity: number | null
name: string
sort_order: number | null
}
Insert: {
avg_intensity?: number | null
category_id?: string | null
created_at?: string | null
description?: string | null
@@ -669,10 +915,13 @@ export type Database = {
is_explicit?: boolean
is_premium?: boolean | null
is_public?: boolean | null
max_intensity?: number | null
min_intensity?: number | null
name: string
sort_order?: number | null
}
Update: {
avg_intensity?: number | null
category_id?: string | null
created_at?: string | null
description?: string | null
@@ -681,6 +930,8 @@ export type Database = {
is_explicit?: boolean
is_premium?: boolean | null
is_public?: boolean | null
max_intensity?: number | null
min_intensity?: number | null
name?: string
sort_order?: number | null
}
@@ -850,6 +1101,86 @@ export type Database = {
}
Relationships: []
}
sent_dares: {
Row: {
accepted_at: string | null
completed_at: string | null
couple_id: string
created_at: string
custom_dare_intensity: number | null
custom_dare_text: string | null
dare_id: string | null
expires_at: string | null
id: string
recipient_id: string
sender_id: string
sender_notes: string | null
sent_at: string
status: Database["public"]["Enums"]["dare_status"]
}
Insert: {
accepted_at?: string | null
completed_at?: string | null
couple_id: string
created_at?: string
custom_dare_intensity?: number | null
custom_dare_text?: string | null
dare_id?: string | null
expires_at?: string | null
id?: string
recipient_id: string
sender_id: string
sender_notes?: string | null
sent_at?: string
status?: Database["public"]["Enums"]["dare_status"]
}
Update: {
accepted_at?: string | null
completed_at?: string | null
couple_id?: string
created_at?: string
custom_dare_intensity?: number | null
custom_dare_text?: string | null
dare_id?: string | null
expires_at?: string | null
id?: string
recipient_id?: string
sender_id?: string
sender_notes?: string | null
sent_at?: string
status?: Database["public"]["Enums"]["dare_status"]
}
Relationships: [
{
foreignKeyName: "sent_dares_couple_id_fkey"
columns: ["couple_id"]
isOneToOne: false
referencedRelation: "couples"
referencedColumns: ["id"]
},
{
foreignKeyName: "sent_dares_dare_id_fkey"
columns: ["dare_id"]
isOneToOne: false
referencedRelation: "dares"
referencedColumns: ["id"]
},
{
foreignKeyName: "sent_dares_recipient_id_fkey"
columns: ["recipient_id"]
isOneToOne: false
referencedRelation: "profiles"
referencedColumns: ["id"]
},
{
foreignKeyName: "sent_dares_sender_id_fkey"
columns: ["sender_id"]
isOneToOne: false
referencedRelation: "profiles"
referencedColumns: ["id"]
},
]
}
subscriptions: {
Row: {
cancel_reason: string | null
@@ -964,6 +1295,14 @@ export type Database = {
}[]
}
get_auth_user_couple_id: { Args: never; Returns: string }
get_couple_dare_stats: {
Args: { p_couple_id: string }
Returns: {
total_dares_active: number
total_dares_completed: number
total_dares_sent: number
}[]
}
get_daily_response_limit_status: {
Args: never
Returns: {
@@ -1018,6 +1357,15 @@ export type Database = {
text: string
}[]
}
get_user_dare_stats: {
Args: { p_user_id: string }
Returns: {
dares_completed_as_recipient: number
dares_completed_as_sender: number
dares_received_count: number
dares_sent_count: number
}[]
}
get_user_media_files: {
Args: { user_id: string }
Returns: {
@@ -1053,11 +1401,22 @@ export type Database = {
Args: { p_code: string; p_email: string }
Returns: Json
}
update_question_pack_intensity_stats: {
Args: { target_pack_id: string }
Returns: undefined
}
}
Enums: {
admin_role: "pack_creator" | "super_admin"
answer_type: "yes" | "no" | "maybe"
audit_action: "INSERT" | "UPDATE" | "DELETE"
dare_status:
| "pending"
| "active"
| "completed"
| "expired"
| "declined"
| "cancelled"
feedback_status:
| "new"
| "reviewed"
@@ -1066,6 +1425,7 @@ export type Database = {
| "closed"
feedback_type: "bug" | "feature_request" | "general" | "question"
match_type: "yes_yes" | "yes_maybe" | "maybe_maybe"
report_reason: "harassment" | "spam" | "inappropriate_content" | "other"
subscription_status:
| "active"
| "cancelled"
@@ -1202,9 +1562,18 @@ export const Constants = {
admin_role: ["pack_creator", "super_admin"],
answer_type: ["yes", "no", "maybe"],
audit_action: ["INSERT", "UPDATE", "DELETE"],
dare_status: [
"pending",
"active",
"completed",
"expired",
"declined",
"cancelled",
],
feedback_status: ["new", "reviewed", "in_progress", "resolved", "closed"],
feedback_type: ["bug", "feature_request", "general", "question"],
match_type: ["yes_yes", "yes_maybe", "maybe_maybe"],
report_reason: ["harassment", "spam", "inappropriate_content", "other"],
subscription_status: [
"active",
"cancelled",
@@ -0,0 +1,255 @@
-- Add visibility toggle for categories
ALTER TABLE public.categories
ADD COLUMN IF NOT EXISTS is_public BOOLEAN NOT NULL DEFAULT true;
-- Categories: hide non-public categories from non-admins
DROP POLICY IF EXISTS "Categories are viewable by everyone" ON public.categories;
CREATE POLICY "Categories are viewable by everyone"
ON public.categories FOR SELECT
USING (is_public = true);
-- Question packs: require public categories for visibility
DROP POLICY IF EXISTS "Anyone can view public packs" ON public.question_packs;
CREATE POLICY "Anyone can view public packs"
ON public.question_packs FOR SELECT
USING (
is_public = true
AND (
category_id IS NULL
OR EXISTS (
SELECT 1
FROM public.categories c
WHERE c.id = question_packs.category_id
AND c.is_public = true
)
)
);
DROP POLICY IF EXISTS "Premium users can view premium packs" ON public.question_packs;
CREATE POLICY "Premium users can view premium packs"
ON public.question_packs FOR SELECT
USING (
(is_premium = false OR EXISTS (
SELECT 1 FROM public.profiles
WHERE profiles.id = auth.uid()
AND profiles.is_premium = true
))
AND (
category_id IS NULL
OR EXISTS (
SELECT 1
FROM public.categories c
WHERE c.id = question_packs.category_id
AND c.is_public = true
)
)
);
-- Questions: require visible packs and public categories
DROP POLICY IF EXISTS "Anyone can view questions in visible packs" ON public.questions;
CREATE POLICY "Anyone can view questions in visible packs"
ON public.questions FOR SELECT
USING (
pack_id IN (
SELECT id FROM public.question_packs
WHERE (
is_public = true
OR (is_premium = true AND EXISTS (
SELECT 1 FROM public.profiles
WHERE profiles.id = auth.uid()
AND profiles.is_premium = true
))
)
AND (
category_id IS NULL
OR EXISTS (
SELECT 1
FROM public.categories c
WHERE c.id = question_packs.category_id
AND c.is_public = true
)
)
)
);
-- Ensure recommendation queries honor category visibility
CREATE OR REPLACE FUNCTION get_recommended_questions(target_pack_id UUID DEFAULT NULL)
RETURNS TABLE(
id UUID,
text TEXT,
partner_text TEXT,
is_two_part BOOLEAN,
pack_id UUID,
intensity INTEGER,
partner_answered BOOLEAN,
allowed_couple_genders TEXT[],
target_user_genders TEXT[]
)
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = 'public'
AS $$
DECLARE
v_user_id UUID;
v_couple_id UUID;
v_user_gender TEXT;
v_partner_gender TEXT;
v_couple_type TEXT;
v_has_premium BOOLEAN;
BEGIN
v_user_id := auth.uid();
SELECT p.couple_id, p.gender INTO v_couple_id, v_user_gender
FROM public.profiles p
WHERE p.id = v_user_id;
IF v_couple_id IS NULL THEN
RETURN;
END IF;
SELECT p.gender INTO v_partner_gender
FROM public.profiles p
WHERE p.couple_id = v_couple_id AND p.id != v_user_id
LIMIT 1;
IF v_user_gender IS NOT NULL AND v_partner_gender IS NOT NULL THEN
IF v_user_gender < v_partner_gender THEN
v_couple_type := v_user_gender || '+' || v_partner_gender;
ELSE
v_couple_type := v_partner_gender || '+' || v_user_gender;
END IF;
END IF;
v_has_premium := public.has_premium_access(v_user_id);
RETURN QUERY
WITH user_config_exists AS (
SELECT 1 FROM public.couple_packs WHERE couple_id = v_couple_id LIMIT 1
),
active_packs AS (
SELECT target_pack_id AS pack_id
WHERE target_pack_id IS NOT NULL
AND EXISTS (
SELECT 1 FROM public.question_packs qp
WHERE qp.id = target_pack_id
AND (qp.is_premium = FALSE OR v_has_premium = TRUE)
AND (
qp.category_id IS NULL
OR EXISTS (
SELECT 1
FROM public.categories c
WHERE c.id = qp.category_id
AND c.is_public = TRUE
)
)
)
UNION ALL
SELECT cp.pack_id FROM public.couple_packs cp
INNER JOIN public.question_packs qp ON qp.id = cp.pack_id
WHERE target_pack_id IS NULL
AND cp.couple_id = v_couple_id
AND cp.enabled = TRUE
AND (qp.is_premium = FALSE OR v_has_premium = TRUE)
AND (
qp.category_id IS NULL
OR EXISTS (
SELECT 1
FROM public.categories c
WHERE c.id = qp.category_id
AND c.is_public = TRUE
)
)
AND EXISTS (SELECT 1 FROM user_config_exists)
UNION ALL
SELECT p.id AS pack_id FROM public.question_packs p
WHERE target_pack_id IS NULL
AND p.is_public = TRUE
AND (p.is_premium = FALSE OR v_has_premium = TRUE)
AND (
p.category_id IS NULL
OR EXISTS (
SELECT 1
FROM public.categories c
WHERE c.id = p.category_id
AND c.is_public = TRUE
)
)
AND NOT EXISTS (SELECT 1 FROM user_config_exists)
)
SELECT
q.id,
CASE
WHEN q.partner_text IS NOT NULL AND EXISTS (
SELECT 1 FROM public.responses r
WHERE r.question_id = q.id
AND r.couple_id = v_couple_id
AND r.user_id != v_user_id
) THEN q.partner_text
ELSE q.text
END as text,
q.partner_text,
q.partner_text IS NOT NULL as is_two_part,
q.pack_id,
q.intensity,
EXISTS (
SELECT 1 FROM public.responses r
WHERE r.question_id = q.id
AND r.couple_id = v_couple_id
AND r.user_id != v_user_id
) AS partner_answered,
q.allowed_couple_genders,
q.target_user_genders
FROM public.questions q
WHERE q.pack_id IN (SELECT ap.pack_id FROM active_packs ap)
AND NOT EXISTS (
SELECT 1 FROM public.responses r
WHERE r.question_id = q.id
AND r.user_id = v_user_id
)
AND (
q.allowed_couple_genders IS NULL
OR v_couple_type IS NULL
OR v_couple_type = ANY(q.allowed_couple_genders)
)
AND (
q.target_user_genders IS NULL
OR v_user_gender = ANY(q.target_user_genders)
OR EXISTS (
SELECT 1 FROM public.responses r
WHERE r.question_id = q.id
AND r.couple_id = v_couple_id
AND r.user_id != v_user_id
)
);
END;
$$;
CREATE OR REPLACE FUNCTION get_pack_teaser_questions(target_pack_id UUID)
RETURNS TABLE(id UUID, text TEXT, intensity INTEGER)
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = 'public'
AS $$
BEGIN
RETURN QUERY
SELECT
q.id,
q.text,
q.intensity
FROM public.questions q
INNER JOIN public.question_packs qp ON qp.id = q.pack_id
WHERE q.pack_id = target_pack_id
AND qp.is_public = TRUE
AND (
qp.category_id IS NULL
OR EXISTS (
SELECT 1
FROM public.categories c
WHERE c.id = qp.category_id
AND c.is_public = TRUE
)
)
ORDER BY q.intensity ASC, random()
LIMIT 3;
END;
$$;
@@ -0,0 +1,7 @@
ALTER TABLE ai_config
ADD COLUMN default_temperature float,
ADD COLUMN temperature_generate float,
ADD COLUMN temperature_fix float,
ADD COLUMN temperature_polish float,
ADD COLUMN council_reviewer_temperature float,
ADD COLUMN classifier_temperature float;
@@ -0,0 +1,5 @@
ALTER TABLE public.questions
ADD COLUMN required_props TEXT[];
COMMENT ON COLUMN public.questions.required_props IS
'Array of props/accessories required to perform the activity.';
@@ -0,0 +1,45 @@
-- Add inverse_of column to questions table
-- This allows tracking when questions are inverses of each other
-- (e.g., "Do you want to give X?" and "Do you want to receive X?")
-- The "primary" question has inverse_of = NULL
-- The "secondary" (inverse) question points to its primary
-- Add the column
ALTER TABLE questions
ADD COLUMN inverse_of UUID REFERENCES questions(id) ON DELETE SET NULL;
-- Add a comment to document the column
COMMENT ON COLUMN questions.inverse_of IS 'References the primary question that this question is an inverse of. Used to track question pairs (e.g., give/receive). Primary questions have inverse_of = NULL, secondary questions point to their primary.';
-- Create an index for efficient lookups
CREATE INDEX idx_questions_inverse_of ON questions(inverse_of) WHERE inverse_of IS NOT NULL;
-- Create a function to get unique question count for a pack
-- Unique count = questions that are NOT inverses of another question (i.e., inverse_of IS NULL)
CREATE OR REPLACE FUNCTION get_pack_unique_question_count(pack_uuid UUID)
RETURNS INTEGER
LANGUAGE SQL
STABLE
AS $$
SELECT COUNT(*)::INTEGER
FROM questions
WHERE pack_id = pack_uuid
AND inverse_of IS NULL
AND deleted_at IS NULL;
$$;
-- Grant execute permission to authenticated users
GRANT EXECUTE ON FUNCTION get_pack_unique_question_count(UUID) TO authenticated;
-- Create a view to get pack stats including both total and unique counts
CREATE OR REPLACE VIEW pack_question_stats AS
SELECT
pack_id,
COUNT(*) FILTER (WHERE deleted_at IS NULL) AS total_questions,
COUNT(*) FILTER (WHERE inverse_of IS NULL AND deleted_at IS NULL) AS unique_questions,
COUNT(*) FILTER (WHERE inverse_of IS NOT NULL AND deleted_at IS NULL) AS inverse_questions
FROM questions
GROUP BY pack_id;
-- Grant select on the view
GRANT SELECT ON pack_question_stats TO authenticated;
@@ -0,0 +1,42 @@
-- Add indexes for unindexed foreign keys
-- These improve JOIN and DELETE performance
CREATE INDEX IF NOT EXISTS idx_admin_users_created_by ON public.admin_users(created_by);
CREATE INDEX IF NOT EXISTS idx_ai_config_updated_by ON public.ai_config(updated_by);
CREATE INDEX IF NOT EXISTS idx_app_config_updated_by ON public.app_config(updated_by);
CREATE INDEX IF NOT EXISTS idx_code_redemptions_user_id ON public.code_redemptions(user_id);
CREATE INDEX IF NOT EXISTS idx_couple_packs_pack_id ON public.couple_packs(pack_id);
CREATE INDEX IF NOT EXISTS idx_dare_messages_sender_id ON public.dare_messages(sender_id);
CREATE INDEX IF NOT EXISTS idx_matches_question_id ON public.matches(question_id);
CREATE INDEX IF NOT EXISTS idx_message_reports_reporter_id ON public.message_reports(reporter_id);
CREATE INDEX IF NOT EXISTS idx_message_reports_reviewed_by ON public.message_reports(reviewed_by);
CREATE INDEX IF NOT EXISTS idx_messages_match_id ON public.messages(match_id);
CREATE INDEX IF NOT EXISTS idx_messages_user_id ON public.messages(user_id);
CREATE INDEX IF NOT EXISTS idx_redemption_codes_created_by ON public.redemption_codes(created_by);
CREATE INDEX IF NOT EXISTS idx_responses_question_id ON public.responses(question_id);
CREATE INDEX IF NOT EXISTS idx_sent_dares_dare_id ON public.sent_dares(dare_id);
-- Drop unused indexes (identified by Supabase advisor as never used)
-- These are just consuming storage with no benefit
DROP INDEX IF EXISTS public.idx_feedback_type;
DROP INDEX IF EXISTS public.idx_feedback_status;
DROP INDEX IF EXISTS public.idx_subscriptions_expires_at;
DROP INDEX IF EXISTS public.idx_webhook_events_event_id;
DROP INDEX IF EXISTS public.idx_audit_logs_record_id;
DROP INDEX IF EXISTS public.idx_audit_logs_action;
DROP INDEX IF EXISTS public.idx_pack_topics_topic_id;
DROP INDEX IF EXISTS public.idx_feature_interests_feature_name;
DROP INDEX IF EXISTS public.idx_profiles_couple_id_public_key;
DROP INDEX IF EXISTS public.idx_messages_deleted_at;
DROP INDEX IF EXISTS public.idx_message_reports_created;
DROP INDEX IF EXISTS public.idx_dare_packs_visibility;
DROP INDEX IF EXISTS public.idx_dares_pack_id;
DROP INDEX IF EXISTS public.idx_dares_intensity;
DROP INDEX IF EXISTS public.idx_sent_dares_sender_id;
DROP INDEX IF EXISTS public.idx_sent_dares_recipient_id;
DROP INDEX IF EXISTS public.idx_sent_dares_status;
DROP INDEX IF EXISTS public.idx_sent_dares_expires_at;
DROP INDEX IF EXISTS public.idx_dare_messages_sent_dare_id;
DROP INDEX IF EXISTS public.idx_dare_messages_created_at;
DROP INDEX IF EXISTS public.idx_questions_deleted_at;
+5
View File
@@ -0,0 +1,5 @@
{
"name": "@sauci/supabase",
"version": "1.0.0",
"private": true
}
+1
View File
@@ -49,6 +49,7 @@ erDiagram
int intensity "1-5"
text[] allowed_couple_genders "couple composition filter"
text[] target_user_genders "individual user filter"
text[] required_props "props/accessories required"
timestamptz created_at
}
File diff suppressed because it is too large. Load diff
+796
View File
@@ -0,0 +1,796 @@
# Missing Inverse Pairs - Review Document
This document lists all asymmetric questions that are missing their inverse pair.
For each question, the **inverse** would swap the `text` and `partner_text` fields,
allowing both partners to experience both roles.
## Summary
- **Total missing inverses:** 607
- **Categories affected:** 10
- **Packs affected:** 28
## How Inverse Pairs Work
For an asymmetric question like:
```
text: "Spank your partner"
partner_text: "Be spanked by your partner"
```
The inverse would be:
```
text: "Be spanked by your partner"
partner_text: "Spank your partner"
```
This ensures both partners get asked about both roles.
---
# Adventure & Travel (1 missing)
## ✨ Food & Culture (1 missing)
| # | Current Text | Current Partner Text | Intensity | Inverse to Create |
|---|--------------|---------------------|-----------|-------------------|
| 1 | Cook a traditional dish from your partner's heritage | Teach your partner to cook a dish from your heritage | 1 | text: "Teach your partner to cook a dish from your heritage" / partner: "Cook a traditional dish from your partner's heritage" |
# Bedroom Adventures (40 missing)
## 🔞 Intimate Games (3 missing)
| # | Current Text | Current Partner Text | Intensity | Inverse to Create |
|---|--------------|---------------------|-----------|-------------------|
| 1 | Take turns being the 'sex instructor' giving commands | Follow your partner's commands as they instruct | 4 | text: "Follow your partner's commands as they instruct" / partner: "Take turns being the 'sex instructor' giving commands" |
| 2 | Blindfold your partner and have them guess what you're doing | Be blindfolded and guess what your partner is doing | 4 | text: "Be blindfolded and guess what your partner is doing" / partner: "Blindfold your partner and have them guess what you're doing" |
| 3 | Use a timer and give your partner pleasure for exactly 5 minutes | Receive exactly 5 minutes of pleasure from your partner | 4 | text: "Receive exactly 5 minutes of pleasure from your partner" / partner: "Use a timer and give your partner pleasure for exactly 5 minutes" |
## 🔞 Position Exploration (10 missing)
| # | Current Text | Current Partner Text | Intensity | Inverse to Create |
|---|--------------|---------------------|-----------|-------------------|
| 1 | Have sex in missionary with a pillow under your partner's hips | Have sex in missionary with a pillow under your hips | 4 | text: "Have sex in missionary with a pillow under your hips" / partner: "Have sex in missionary with a pillow under your partner's hips" |
| 2 | Let your partner choose three sex positions for tonight | Choose three sex positions for tonight | 3 | text: "Choose three sex positions for tonight" / partner: "Let your partner choose three sex positions for tonight" |
| 3 | Have sex with your partner bent over furniture | Be bent over furniture while your partner has sex with you | 4 | text: "Be bent over furniture while your partner has sex with you" / partner: "Have sex with your partner bent over furniture" |
| 4 | Lift your partner while having sex | Be lifted by your partner while having sex | 4 | text: "Be lifted by your partner while having sex" / partner: "Lift your partner while having sex" |
| 5 | Have sex with your partner on top facing you | Ride your partner facing them | 5 | text: "Ride your partner facing them" / partner: "Have sex with your partner on top facing you" |
| 6 | Have sex with your partner's legs on your shoulders | Have sex with your legs on your partner's shoulders | 4 | text: "Have sex with your legs on your partner's shoulders" / partner: "Have sex with your partner's legs on your shoulders" |
| 7 | Have sex with your partner's ankles by their ears | Have sex with your ankles by your ears | 4 | text: "Have sex with your ankles by your ears" / partner: "Have sex with your partner's ankles by their ears" |
| 8 | Have your partner wrap their legs around you during sex | Wrap your legs around your partner during sex | 4 | text: "Wrap your legs around your partner during sex" / partner: "Have your partner wrap their legs around you during sex" |
| 9 | Perform oral while your partner is seated | Receive oral while seated | 3 | text: "Receive oral while seated" / partner: "Perform oral while your partner is seated" |
| 10 | Have sex in a chair with your partner in your lap | Have sex in your partner's lap while sitting in a chair | 4 | text: "Have sex in your partner's lap while sitting in a chair" / partner: "Have sex in a chair with your partner in your lap" |
## 🔞 Sensory Play (27 missing)
| # | Current Text | Current Partner Text | Intensity | Inverse to Create |
|---|--------------|---------------------|-----------|-------------------|
| 1 | Apply menthol or tingling lube to sensitive areas | Have tingling lube applied to sensitive areas | 4 | text: "Have tingling lube applied to sensitive areas" / partner: "Apply menthol or tingling lube to sensitive areas" |
| 2 | Use noise-canceling headphones and surprise your partner | Wear headphones and be surprised | 4 | text: "Wear headphones and be surprised" / partner: "Use noise-canceling headphones and surprise your partner" |
| 3 | Kiss your partner everywhere while they keep their eyes closed | Keep your eyes closed while being kissed everywhere | 4 | text: "Keep your eyes closed while being kissed everywhere" / partner: "Kiss your partner everywhere while they keep their eyes closed" |
| 4 | Trace words or patterns on your partner's back and have them guess | Guess what your partner is tracing on your back | 3 | text: "Guess what your partner is tracing on your back" / partner: "Trace words or patterns on your partner's back and have them guess" |
| 5 | Use a pinwheel or scratchy texture on your partner's skin | Feel a pinwheel or scratchy texture on your skin | 4 | text: "Feel a pinwheel or scratchy texture on your skin" / partner: "Use a pinwheel or scratchy texture on your partner's skin" |
| 6 | Blindfold your partner and use only your tongue | Be blindfolded while your partner uses only their tongue | 4 | text: "Be blindfolded while your partner uses only their tongue" / partner: "Blindfold your partner and use only your tongue" |
| 7 | Cover your partner's body in whipped cream and lick it off | Have whipped cream licked off your body | 4 | text: "Have whipped cream licked off your body" / partner: "Cover your partner's body in whipped cream and lick it off" |
| 8 | Use your hair to tickle and tease your partner | Be tickled and teased with your partner's hair | 3 | text: "Be tickled and teased with your partner's hair" / partner: "Use your hair to tickle and tease your partner" |
| 9 | Apply ice to your lips before kissing your partner everywhere | Be kissed everywhere with cold lips | 4 | text: "Be kissed everywhere with cold lips" / partner: "Apply ice to your lips before kissing your partner everywhere" |
| 10 | Use a vibrator on your partner while they're blindfolded | Experience a vibrator while blindfolded | 4 | text: "Experience a vibrator while blindfolded" / partner: "Use a vibrator on your partner while they're blindfolded" |
| 11 | Cover your partner's eyes and whisper dirty talk | Have your eyes covered while hearing dirty talk | 4 | text: "Have your eyes covered while hearing dirty talk" / partner: "Cover your partner's eyes and whisper dirty talk" |
| 12 | Use different textures on your partner's skin during foreplay | Experience different textures during foreplay | 4 | text: "Experience different textures during foreplay" / partner: "Use different textures on your partner's skin during foreplay" |
| 13 | Drip candle wax on your partner (massage candle) | Have massage candle wax dripped on you | 4 | text: "Have massage candle wax dripped on you" / partner: "Drip candle wax on your partner (massage candle)" |
| 14 | Use a blindfold and have your partner guess what toy you're using | Guess what toy is being used on you while blindfolded | 4 | text: "Guess what toy is being used on you while blindfolded" / partner: "Use a blindfold and have your partner guess what toy you're using" |
| 15 | Use silk scarves to restrain your partner lightly | Be lightly restrained with silk scarves | 5 | text: "Be lightly restrained with silk scarves" / partner: "Use silk scarves to restrain your partner lightly" |
| 16 | Blindfold your partner and feed them aphrodisiacs | Be blindfolded and fed aphrodisiacs | 3 | text: "Be blindfolded and fed aphrodisiacs" / partner: "Blindfold your partner and feed them aphrodisiacs" |
| 17 | Use a wartenberg wheel on your partner's skin | Have a wartenberg wheel used on your skin | 4 | text: "Have a wartenberg wheel used on your skin" / partner: "Use a wartenberg wheel on your partner's skin" |
| 18 | Use flavored lube and let your partner guess the flavor | Guess the flavor of lube your partner is using | 4 | text: "Guess the flavor of lube your partner is using" / partner: "Use flavored lube and let your partner guess the flavor" |
| 19 | Put earplugs in your partner and surprise them with touch | Wear earplugs and be surprised by touch | 4 | text: "Wear earplugs and be surprised by touch" / partner: "Put earplugs in your partner and surprise them with touch" |
| 20 | Use a feather to tease your partner's most sensitive areas | Be teased with a feather on your sensitive areas | 4 | text: "Be teased with a feather on your sensitive areas" / partner: "Use a feather to tease your partner's most sensitive areas" |
| 21 | Drip warm massage oil on your partner | Have warm oil dripped on your body | 4 | text: "Have warm oil dripped on your body" / partner: "Drip warm massage oil on your partner" |
| 22 | Use ice on your partner's body during foreplay | Have ice used on your body during foreplay | 4 | text: "Have ice used on your body during foreplay" / partner: "Use ice on your partner's body during foreplay" |
| 23 | Blindfold your partner during sex | Be blindfolded during sex | 5 | text: "Be blindfolded during sex" / partner: "Blindfold your partner during sex" |
| 24 | Use a fur mitt or soft glove during foreplay | Experience fur or soft glove during foreplay | 3 | text: "Experience fur or soft glove during foreplay" / partner: "Use a fur mitt or soft glove during foreplay" |
| 25 | Blindfold your partner in the shower and wash them sensually | Be blindfolded in the shower and washed sensually | 4 | text: "Be blindfolded in the shower and washed sensually" / partner: "Blindfold your partner in the shower and wash them sensually" |
| 26 | Tie your partner's hands loosely and tease them | Have your hands tied loosely and be teased | 5 | text: "Have your hands tied loosely and be teased" / partner: "Tie your partner's hands loosely and tease them" |
| 27 | Use your breath to create sensations on your partner's body | Feel your partner's breath creating sensations | 4 | text: "Feel your partner's breath creating sensations" / partner: "Use your breath to create sensations on your partner's body" |
# Fantasy Exploration (46 missing)
## 🔞 Dream Scenarios (4 missing)
| # | Current Text | Current Partner Text | Intensity | Inverse to Create |
|---|--------------|---------------------|-----------|-------------------|
| 1 | Share a fantasy about your partner surprising you sexually | Share a fantasy about surprising your partner sexually | 4 | text: "Share a fantasy about surprising your partner sexually" / partner: "Share a fantasy about your partner surprising you sexually" |
| 2 | Share your biggest sexual fantasy with your partner | Hear your partner's biggest sexual fantasy | 4 | text: "Hear your partner's biggest sexual fantasy" / partner: "Share your biggest sexual fantasy with your partner" |
| 3 | Share a fantasy about seducing your partner | Share a fantasy about being seduced | 3 | text: "Share a fantasy about being seduced" / partner: "Share a fantasy about seducing your partner" |
| 4 | Describe a scenario where you worship your partner's body | Describe a scenario where your body is worshipped | 4 | text: "Describe a scenario where your body is worshipped" / partner: "Describe a scenario where you worship your partner's body" |
## 🔞 Language Exploration (14 missing)
| # | Current Text | Current Partner Text | Intensity | Inverse to Create |
|---|--------------|---------------------|-----------|-------------------|
| 1 | Degrade your partner with dirty talk | Be sexually degraded by your partner's dirty talk | 5 | text: "Be sexually degraded by your partner's dirty talk" / partner: "Degrade your partner with dirty talk" |
| 2 | Use praise like 'good girl' or 'good boy' on your partner | Be praised as a 'good girl' or 'good boy' by your partner | 3 | text: "Be praised as a 'good girl' or 'good boy' by your partner" / partner: "Use praise like 'good girl' or 'good boy' on your partner" |
| 3 | Make your partner call you 'Daddy' | Let your partner make you call them 'Daddy' | 5 | text: "Let your partner make you call them 'Daddy'" / partner: "Make your partner call you 'Daddy'" |
| 4 | Dictate captions for your partner's nude poses | Pose for nudes while your partner dictates the captions | 4 | text: "Pose for nudes while your partner dictates the captions" / partner: "Dictate captions for your partner's nude poses" |
| 5 | Narrate graphic fantasies while edging your partner | Be edged while listening to your partner's graphic fantasies | 5 | text: "Be edged while listening to your partner's graphic fantasies" / partner: "Narrate graphic fantasies while edging your partner" |
| 6 | Make your partner beg for release | Beg your partner for permission to cum | 5 | text: "Beg your partner for permission to cum" / partner: "Make your partner beg for release" |
| 7 | Whisper vivid descriptions of your partner’s physical submission | Listen to your partner describe your body's total surrender | 4 | text: "Listen to your partner describe your body's total surrender" / partner: "Whisper vivid descriptions of your partner’s physical submission" |
| 8 | Whisper forbidden fantasies into your partner's ear | Listen to your partner's darkest desires | 3 | text: "Listen to your partner's darkest desires" / partner: "Whisper forbidden fantasies into your partner's ear" |
| 9 | Describe your partner's body in explicit detail | Have your body described in explicit detail by your partner | 3 | text: "Have your body described in explicit detail by your partner" / partner: "Describe your partner's body in explicit detail" |
| 10 | Correct your partner’s language using physical discipline | Receive physical correction for using the wrong words | 5 | text: "Receive physical correction for using the wrong words" / partner: "Correct your partner’s language using physical discipline" |
| 11 | Use a ball gag to silence your partner's protests | Experience being silenced and controlled with a ball gag | 5 | text: "Experience being silenced and controlled with a ball gag" / partner: "Use a ball gag to silence your partner's protests" |
| 12 | Narrate exactly how you will fuck your partner | Listen to your partner's detailed fucking plan | 4 | text: "Listen to your partner's detailed fucking plan" / partner: "Narrate exactly how you will fuck your partner" |
| 13 | Record a dirty voice memo of your explicit plans for your partner | Receive a dirty voice memo detailing your partner's explicit plans | 3 | text: "Receive a dirty voice memo detailing your partner's explicit plans" / partner: "Record a dirty voice memo of your explicit plans for your partner" |
| 14 | Describe your partner's taste in graphic detail | Listen to your partner describe your taste | 4 | text: "Listen to your partner describe your taste" / partner: "Describe your partner's taste in graphic detail" |
## 🔞 Role Play Ideas (3 missing)
| # | Current Text | Current Partner Text | Intensity | Inverse to Create |
|---|--------------|---------------------|-----------|-------------------|
| 1 | Act out being seduced by a mysterious stranger | Act out seducing someone as a mysterious stranger | 4 | text: "Act out seducing someone as a mysterious stranger" / partner: "Act out being seduced by a mysterious stranger" |
| 2 | Role play as a stripper giving a private dance | Role play as someone receiving a private dance | 4 | text: "Role play as someone receiving a private dance" / partner: "Role play as a stripper giving a private dance" |
| 3 | Role play as a doctor giving an examination | Role play as a patient getting an examination | 5 | text: "Role play as a patient getting an examination" / partner: "Role play as a doctor giving an examination" |
## 🔞 Secret Desires (25 missing)
| # | Current Text | Current Partner Text | Intensity | Inverse to Create |
|---|--------------|---------------------|-----------|-------------------|
| 1 | Tell your partner what you want them to do more often | Hear what your partner wants you to do more often | 2 | text: "Hear what your partner wants you to do more often" / partner: "Tell your partner what you want them to do more often" |
| 2 | Confess a desire you've been too shy to mention | Hear a desire your partner has been too shy to mention | 3 | text: "Hear a desire your partner has been too shy to mention" / partner: "Confess a desire you've been too shy to mention" |
| 3 | Share a sexual 'bucket list' experience you want to have | Hear a sexual 'bucket list' experience your partner wants | 3 | text: "Hear a sexual 'bucket list' experience your partner wants" / partner: "Share a sexual 'bucket list' experience you want to have" |
| 4 | Reveal what you secretly want to hear during sex | Learn what your partner wants to hear during sex | 3 | text: "Learn what your partner wants to hear during sex" / partner: "Reveal what you secretly want to hear during sex" |
| 5 | Share a fantasy involving voyeurism | Hear your partner's fantasy about being watched or watching | 3 | text: "Hear your partner's fantasy about being watched or watching" / partner: "Share a fantasy involving voyeurism" |
| 6 | Describe what you want to say or hear during dirty talk | Hear what your partner wants to say or hear during dirty talk | 3 | text: "Hear what your partner wants to say or hear during dirty talk" / partner: "Describe what you want to say or hear during dirty talk" |
| 7 | Describe a specific fantasy involving oral sex | Hear your partner's fantasy about giving or receiving oral | 3 | text: "Hear your partner's fantasy about giving or receiving oral" / partner: "Describe a specific fantasy involving oral sex" |
| 8 | Describe your desire for slow, gentle lovemaking | Hear your partner's desire for slow, gentle lovemaking | 2 | text: "Hear your partner's desire for slow, gentle lovemaking" / partner: "Describe your desire for slow, gentle lovemaking" |
| 9 | Share a fantasy involving exhibitionism | Hear your partner's fantasy about being seen | 3 | text: "Hear your partner's fantasy about being seen" / partner: "Share a fantasy involving exhibitionism" |
| 10 | Share what you fantasize about while masturbating | Hear what your partner fantasizes about while masturbating | 3 | text: "Hear what your partner fantasizes about while masturbating" / partner: "Share what you fantasize about while masturbating" |
| 11 | Describe your desire for rougher sex | Hear your partner's desire for rougher sex | 3 | text: "Hear your partner's desire for rougher sex" / partner: "Describe your desire for rougher sex" |
| 12 | Describe your hottest sexual fantasy involving your partner | Hear your partner's hottest sexual fantasy about you | 3 | text: "Hear your partner's hottest sexual fantasy about you" / partner: "Describe your hottest sexual fantasy involving your partner" |
| 13 | Share a desire about how you want to be touched | Learn how your partner wants to be touched | 4 | text: "Learn how your partner wants to be touched" / partner: "Share a desire about how you want to be touched" |
| 14 | Share a secret turn-on that might surprise your partner | Learn a secret turn-on from your partner | 4 | text: "Learn a secret turn-on from your partner" / partner: "Share a secret turn-on that might surprise your partner" |
| 15 | Tell your partner the one thing that would make sex perfect | Hear what would make sex perfect for your partner | 4 | text: "Hear what would make sex perfect for your partner" / partner: "Tell your partner the one thing that would make sex perfect" |
| 16 | Explain your desire to be the submissive one | Hear about your partner's desire to be submissive | 3 | text: "Hear about your partner's desire to be submissive" / partner: "Explain your desire to be the submissive one" |
| 17 | Describe the type of porn that turns you on | Hear about the porn that turns your partner on | 3 | text: "Hear about the porn that turns your partner on" / partner: "Describe the type of porn that turns you on" |
| 18 | Describe a taboo fantasy that excites you | Hear about a taboo fantasy that excites your partner | 3 | text: "Hear about a taboo fantasy that excites your partner" / partner: "Describe a taboo fantasy that excites you" |
| 19 | Reveal a kink you're curious about exploring | Hear about a kink your partner wants to explore | 3 | text: "Hear about a kink your partner wants to explore" / partner: "Reveal a kink you're curious about exploring" |
| 20 | Describe your desire to be worshipped in bed | Hear about your partner's desire to be worshipped | 3 | text: "Hear about your partner's desire to be worshipped" / partner: "Describe your desire to be worshipped in bed" |
| 21 | Reveal a location where you want to have sex | Hear where your partner secretly wants to have sex | 3 | text: "Hear where your partner secretly wants to have sex" / partner: "Reveal a location where you want to have sex" |
| 22 | Explain your desire to be the dominant one | Hear about your partner's desire to be dominant | 3 | text: "Hear about your partner's desire to be dominant" / partner: "Explain your desire to be the dominant one" |
| 23 | Share a desire to have sex multiple times in one day | Hear your partner's desire for multiple rounds of sex | 3 | text: "Hear your partner's desire for multiple rounds of sex" / partner: "Share a desire to have sex multiple times in one day" |
| 24 | Describe your ultimate sexual fantasy | Hear your partner's ultimate sexual fantasy in detail | 3 | text: "Hear your partner's ultimate sexual fantasy in detail" / partner: "Describe your ultimate sexual fantasy" |
| 25 | Describe a fantasy involving teasing and denial | Hear your partner's fantasy about teasing and denial | 3 | text: "Hear your partner's fantasy about teasing and denial" / partner: "Describe a fantasy involving teasing and denial" |
# Getting Started (5 missing)
## 🔞 Know Your Limits (5 missing)
| # | Current Text | Current Partner Text | Intensity | Inverse to Create |
|---|--------------|---------------------|-----------|-------------------|
| 1 | Wear a butt plug during sex | Have your partner wear a butt plug during sex | 4 | text: "Have your partner wear a butt plug during sex" / partner: "Wear a butt plug during sex" |
| 2 | Swallow when giving your partner oral | Have your partner swallow | 4 | text: "Have your partner swallow" / partner: "Swallow when giving your partner oral" |
| 3 | Deep throat your partner | Have your partner deep throat you | 4 | text: "Have your partner deep throat you" / partner: "Deep throat your partner" |
| 4 | Have your partner cum inside you | Cum inside your partner | 4 | text: "Cum inside your partner" / partner: "Have your partner cum inside you" |
| 5 | Have your partner cum on your body | Cum on your partner's body | 4 | text: "Cum on your partner's body" / partner: "Have your partner cum on your body" |
# Long Distance (106 missing)
## ✨ Missing You (30 missing)
| # | Current Text | Current Partner Text | Intensity | Inverse to Create |
|---|--------------|---------------------|-----------|-------------------|
| 1 | Send a photo of your hand and say you wish you were holding theirs | Receive a sweet reminder of how much your partner wants to hold you | 1 | text: "Receive a sweet reminder of how much your partner wants to hold you" / partner: "Send a photo of your hand and say you wish you were holding theirs" |
| 2 | Tell your partner you saw someone who reminded you of them | Hear how often your partner has you on their mind | 1 | text: "Hear how often your partner has you on their mind" / partner: "Tell your partner you saw someone who reminded you of them" |
| 3 | Tell your partner what you'd be doing right now if they were there | Hear what your partner wishes you were doing together | 2 | text: "Hear what your partner wishes you were doing together" / partner: "Tell your partner what you'd be doing right now if they were there" |
| 4 | Send a tipsy voice note telling them you miss them | Get a slightly drunk "I miss you" message | 2 | text: "Get a slightly drunk "I miss you" message" / partner: "Send a tipsy voice note telling them you miss them" |
| 5 | Send a photo of your bed and say it feels empty without them | Receive a sweet photo of your partner's bed and a message that you're missed | 2 | text: "Receive a sweet photo of your partner's bed and a message that you're missed" / partner: "Send a photo of your bed and say it feels empty without them" |
| 6 | Send a photo of an empty seat and say you saved it for them | Receive a photo of a seat saved just for you | 1 | text: "Receive a photo of a seat saved just for you" / partner: "Send a photo of an empty seat and say you saved it for them" |
| 7 | Send a lazy selfie and say you wish you were doing nothing together | Receive a lazy selfie and an invitation to do nothing together | 1 | text: "Receive a lazy selfie and an invitation to do nothing together" / partner: "Send a lazy selfie and say you wish you were doing nothing together" |
| 8 | Text your partner from an event that it would be better with them | Know your partner wishes you were there | 2 | text: "Know your partner wishes you were there" / partner: "Text your partner from an event that it would be better with them" |
| 9 | Tell your partner something small they do that you miss unexpectedly | Hear what little thing your partner misses about you | 2 | text: "Hear what little thing your partner misses about you" / partner: "Tell your partner something small they do that you miss unexpectedly" |
| 10 | FaceTime your partner just to see their face for a few minutes | Get an "I just needed to see you" call | 2 | text: "Get an "I just needed to see you" call" / partner: "FaceTime your partner just to see their face for a few minutes" |
| 11 | Tell your partner what you're counting down to about seeing them | Build excitement for the reunion | 2 | text: "Build excitement for the reunion" / partner: "Tell your partner what you're counting down to about seeing them" |
| 12 | Voice note your partner something you've been meaning to tell them | Hear something meaningful from your partner | 2 | text: "Hear something meaningful from your partner" / partner: "Voice note your partner something you've been meaning to tell them" |
| 13 | Send a voice note from bed saying you miss sleeping next to them | Hear a sleepy voice note about how much you are missed in bed | 2 | text: "Hear a sleepy voice note about how much you are missed in bed" / partner: "Send a voice note from bed saying you miss sleeping next to them" |
| 14 | Tell your partner one thing you'll do to them when you're alone | Hear exactly what your partner has planned for your next reunion | 3 | text: "Hear exactly what your partner has planned for your next reunion" / partner: "Tell your partner one thing you'll do to them when you're alone" |
| 15 | Send a mirror selfie when you are dressed up to go out | Receive a photo of your partner looking their best | 2 | text: "Receive a photo of your partner looking their best" / partner: "Send a mirror selfie when you are dressed up to go out" |
| 16 | Tell your partner what random thing reminded you of them today | Hear that random things trigger memories of you | 2 | text: "Hear that random things trigger memories of you" / partner: "Tell your partner what random thing reminded you of them today" |
| 17 | Text your partner that you caught yourself smiling at your phone because of them | Know you made your partner smile | 2 | text: "Know you made your partner smile" / partner: "Text your partner that you caught yourself smiling at your phone because of them" |
| 18 | Describe the exact way you want them to hold you when you reunite | Feel the anticipation build for their touch | 2 | text: "Feel the anticipation build for their touch" / partner: "Describe the exact way you want them to hold you when you reunite" |
| 19 | Send a sleepy morning selfie, messy hair and all | See your partner's unfiltered morning face | 2 | text: "See your partner's unfiltered morning face" / partner: "Send a sleepy morning selfie, messy hair and all" |
| 20 | Tell your partner what outfit of theirs you can't stop thinking about | Hear what look of yours drives them crazy | 2 | text: "Hear what look of yours drives them crazy" / partner: "Tell your partner what outfit of theirs you can't stop thinking about" |
| 21 | Text your partner at a party that you'd rather be home calling them | Feel chosen over a social event | 2 | text: "Feel chosen over a social event" / partner: "Text your partner at a party that you'd rather be home calling them" |
| 22 | Text your partner first thing in the morning that they were your first thought | Wake up knowing you're on their mind | 2 | text: "Wake up knowing you're on their mind" / partner: "Text your partner first thing in the morning that they were your first thought" |
| 23 | Send a photo of you wearing something of theirs | See your partner in your hoodie | 2 | text: "See your partner in your hoodie" / partner: "Send a photo of you wearing something of theirs" |
| 24 | Tell your partner about a dream you had about them | Hear you were in your partner's dreams | 2 | text: "Hear you were in your partner's dreams" / partner: "Tell your partner about a dream you had about them" |
| 25 | Send a getting-ready selfie and ask if they approve | Give your partner's outfit the seal of approval | 2 | text: "Give your partner's outfit the seal of approval" / partner: "Send a getting-ready selfie and ask if they approve" |
| 26 | Send a voice note when you're half asleep saying goodnight | Hear your partner's sleepy voice | 2 | text: "Hear your partner's sleepy voice" / partner: "Send a voice note when you're half asleep saying goodnight" |
| 27 | Tell your partner something specific about their body you've been thinking about | Hear what physical thing your partner misses | 2 | text: "Hear what physical thing your partner misses" / partner: "Tell your partner something specific about their body you've been thinking about" |
| 28 | Describe exactly how you'd greet them at the airport | Build anticipation for the reunion | 2 | text: "Build anticipation for the reunion" / partner: "Describe exactly how you'd greet them at the airport" |
| 29 | Text your partner during a boring meeting that you're thinking about them | Get a random "thinking of you" during work | 2 | text: "Get a random "thinking of you" during work" / partner: "Text your partner during a boring meeting that you're thinking about them" |
| 30 | Text your partner "wish you were here" from somewhere fun | Hear that your partner wishes you were by their side | 1 | text: "Hear that your partner wishes you were by their side" / partner: "Text your partner "wish you were here" from somewhere fun" |
## 🔞 Private Line (27 missing)
| # | Current Text | Current Partner Text | Intensity | Inverse to Create |
|---|--------------|---------------------|-----------|-------------------|
| 1 | Text your partner what you're wearing, then what you're taking off | Get a strip tease via text | 3 | text: "Get a strip tease via text" / partner: "Text your partner what you're wearing, then what you're taking off" |
| 2 | Send a post-climax photo to your partner | See your partner's glow in a post-climax photo | 4 | text: "See your partner's glow in a post-climax photo" / partner: "Send a post-climax photo to your partner" |
| 3 | Text your partner a play-by-play while you pleasure yourself | Read a play-by-play as your partner pleasures themselves | 3 | text: "Read a play-by-play as your partner pleasures themselves" / partner: "Text your partner a play-by-play while you pleasure yourself" |
| 4 | Voice note your partner while touching yourself - no words, just sounds | Hear your partner pleasure themselves | 4 | text: "Hear your partner pleasure themselves" / partner: "Voice note your partner while touching yourself - no words, just sounds" |
| 5 | Send a voice note of your release while saying their name | Hear your partner's release as they say your name | 4 | text: "Hear your partner's release as they say your name" / partner: "Send a voice note of your release while saying their name" |
| 6 | Share a secret fantasy with your partner in detail | Listen to your partner share a secret fantasy in detail | 2 | text: "Listen to your partner share a secret fantasy in detail" / partner: "Share a secret fantasy with your partner in detail" |
| 7 | Talk your partner through how to touch themselves over the phone | Let your partner guide your touch over the phone | 4 | text: "Let your partner guide your touch over the phone" / partner: "Talk your partner through how to touch themselves over the phone" |
| 8 | Send a photo from the shower thinking about your partner | See your partner wet and thinking of you | 3 | text: "See your partner wet and thinking of you" / partner: "Send a photo from the shower thinking about your partner" |
| 9 | Ask your partner for a specific photo you've been craving | Send your partner a specific photo they've been craving | 4 | text: "Send your partner a specific photo they've been craving" / partner: "Ask your partner for a specific photo you've been craving" |
| 10 | Send your partner a short video of your hand where you wish theirs was | Watch your partner touch themselves for you | 4 | text: "Watch your partner touch themselves for you" / partner: "Send your partner a short video of your hand where you wish theirs was" |
| 11 | Send your partner a video of you getting undressed | Watch your partner undress for you | 3 | text: "Watch your partner undress for you" / partner: "Send your partner a video of you getting undressed" |
| 12 | Send a photo in bed after finishing and tell them it should've been them | See your partner satisfied but wishing you were there | 4 | text: "See your partner satisfied but wishing you were there" / partner: "Send a photo in bed after finishing and tell them it should've been them" |
| 13 | Tell your partner step-by-step what you're going to do when you reunite | Hear your partner's detailed plan for your body | 3 | text: "Hear your partner's detailed plan for your body" / partner: "Tell your partner step-by-step what you're going to do when you reunite" |
| 14 | Video call and make your partner watch without touching until you say so | Be made to watch without touching yourself | 4 | text: "Be made to watch without touching yourself" / partner: "Video call and make your partner watch without touching until you say so" |
| 15 | Send a video of you in their favorite position, waiting | See your partner ready and waiting for you | 4 | text: "See your partner ready and waiting for you" / partner: "Send a video of you in their favorite position, waiting" |
| 16 | Record yourself saying exactly what you'd whisper in their ear in bed | Hear your partner's voice like they're right next to you | 3 | text: "Hear your partner's voice like they're right next to you" / partner: "Record yourself saying exactly what you'd whisper in their ear in bed" |
| 17 | Send your partner a video of you in the mirror, showing off for them | Watch your partner show off their body for you | 3 | text: "Watch your partner show off their body for you" / partner: "Send your partner a video of you in the mirror, showing off for them" |
| 18 | Describe what you're imagining while you touch yourself over text | Read your partner's thoughts while they get off to you | 4 | text: "Read your partner's thoughts while they get off to you" / partner: "Describe what you're imagining while you touch yourself over text" |
| 19 | Send a voice note telling your partner to touch themselves right now | Follow your partner's command from afar | 4 | text: "Follow your partner's command from afar" / partner: "Send a voice note telling your partner to touch themselves right now" |
| 20 | Watch the same video together over FaceTime and see who breaks first | Get turned on together from miles away | 4 | text: "Get turned on together from miles away" / partner: "Watch the same video together over FaceTime and see who breaks first" |
| 21 | Describe what position you want them in the moment you're alone together | Hear how your partner wants to have you | 3 | text: "Hear how your partner wants to have you" / partner: "Describe what position you want them in the moment you're alone together" |
| 22 | Sext your partner discreetly while you are both busy | Exchange discreet, sensual texts while you are both busy | 3 | text: "Exchange discreet, sensual texts while you are both busy" / partner: "Sext your partner discreetly while you are both busy" |
| 23 | Send a voice note describing what you'd do to your partner | Hear your partner describe what they want to do to you | 3 | text: "Hear your partner describe what they want to do to you" / partner: "Send a voice note describing what you'd do to your partner" |
| 24 | Send your partner a sensual morning text | Wake up to a sensual text from your partner | 2 | text: "Wake up to a sensual text from your partner" / partner: "Send your partner a sensual morning text" |
| 25 | Send a teasing text to build anticipation for a later call | Receive a teasing text that builds anticipation for a call | 2 | text: "Receive a teasing text that builds anticipation for a call" / partner: "Send a teasing text to build anticipation for a later call" |
| 26 | Send your partner a teasing, private photo | Receive a teasing, private photo from your partner | 3 | text: "Receive a teasing, private photo from your partner" / partner: "Send your partner a teasing, private photo" |
| 27 | Tell your partner exactly where you want their mouth when you see them | Hear where your partner wants to taste you | 2 | text: "Hear where your partner wants to taste you" / partner: "Tell your partner exactly where you want their mouth when you see them" |
## ✨ Staying Close (19 missing)
| # | Current Text | Current Partner Text | Intensity | Inverse to Create |
|---|--------------|---------------------|-----------|-------------------|
| 1 | Challenge your partner to an online board game | Be challenged to an online board game by your partner | 1 | text: "Be challenged to an online board game by your partner" / partner: "Challenge your partner to an online board game" |
| 2 | Send your partner a voice note on your commute | Get a voice note from your partner during their day | 1 | text: "Get a voice note from your partner during their day" / partner: "Send your partner a voice note on your commute" |
| 3 | Send a random photo of your current view | Receive a candid snapshot of your partner's day | 1 | text: "Receive a candid snapshot of your partner's day" / partner: "Send a random photo of your current view" |
| 4 | Send your partner a care package of their favorite snacks | Receive a snack-filled care package from your partner | 1 | text: "Receive a snack-filled care package from your partner" / partner: "Send your partner a care package of their favorite snacks" |
| 5 | Text your partner updates from a social event they'd hate | Live vicariously through your partner's obligations | 1 | text: "Live vicariously through your partner's obligations" / partner: "Text your partner updates from a social event they'd hate" |
| 6 | Make a shared note of restaurants to try when you're together | Build a list for your next visit | 1 | text: "Build a list for your next visit" / partner: "Make a shared note of restaurants to try when you're together" |
| 7 | Share your coffee order for your next visit | Learn your partner's go-to coffee order | 1 | text: "Learn your partner's go-to coffee order" / partner: "Share your coffee order for your next visit" |
| 8 | Send your partner a meme that only they would understand | Receive an inside joke from your partner | 1 | text: "Receive an inside joke from your partner" / partner: "Send your partner a meme that only they would understand" |
| 9 | Watch a live sports game or event together while texting reactions | React to something live with your partner | 1 | text: "React to something live with your partner" / partner: "Watch a live sports game or event together while texting reactions" |
| 10 | Do the same Wordle and compare results | Compete on a daily puzzle with your partner | 1 | text: "Compete on a daily puzzle with your partner" / partner: "Do the same Wordle and compare results" |
| 11 | Send your partner a song you heard that made you think of them | Get a track that reminded your partner of you | 1 | text: "Get a track that reminded your partner of you" / partner: "Send your partner a song you heard that made you think of them" |
| 12 | Screenshot something funny from your day and send it with no context | Get a random screenshot from your partner | 1 | text: "Get a random screenshot from your partner" / partner: "Screenshot something funny from your day and send it with no context" |
| 13 | Surprise your partner by ordering delivery to their house | Be surprised with a delivery meal from your partner | 1 | text: "Be surprised with a delivery meal from your partner" / partner: "Surprise your partner by ordering delivery to their house" |
| 14 | Share your location with your partner for safety | See your partner's location to know they're safe | 1 | text: "See your partner's location to know they're safe" / partner: "Share your location with your partner for safety" |
| 15 | Send a voice note venting about your day | Listen to your partner vent about their day | 1 | text: "Listen to your partner vent about their day" / partner: "Send a voice note venting about your day" |
| 16 | Video call your partner while you grocery shop | Go grocery shopping virtually with your partner | 1 | text: "Go grocery shopping virtually with your partner" / partner: "Video call your partner while you grocery shop" |
| 17 | Call your partner while you're on a walk just to chat | Take a walk with your partner over the phone | 1 | text: "Take a walk with your partner over the phone" / partner: "Call your partner while you're on a walk just to chat" |
| 18 | Send a photo of something that reminded you of an inside joke | Get a reference only you two would understand | 1 | text: "Get a reference only you two would understand" / partner: "Send a photo of something that reminded you of an inside joke" |
| 19 | Video call your partner during your lunch break | Enjoy a quick lunch date over video call | 1 | text: "Enjoy a quick lunch date over video call" / partner: "Video call your partner during your lunch break" |
## 🔞 Total Control (30 missing)
| # | Current Text | Current Partner Text | Intensity | Inverse to Create |
|---|--------------|---------------------|-----------|-------------------|
| 1 | Set rules your partner must follow until you see each other again | Follow your partner's rules until your next reunion | 5 | text: "Follow your partner's rules until your next reunion" / partner: "Set rules your partner must follow until you see each other again" |
| 2 | Make your partner edge for you on video every night for a week | Edge for your partner on camera every night until they let you finish | 5 | text: "Edge for your partner on camera every night until they let you finish" / partner: "Make your partner edge for you on video every night for a week" |
| 3 | Build a reunion list of everything you'll do when you're finally together | Add to the list of what your partner will do to you when you meet | 4 | text: "Add to the list of what your partner will do to you when you meet" / partner: "Build a reunion list of everything you'll do when you're finally together" |
| 4 | Control your partner's toy on maximum until they use your safe word | Take as much as you can until you have to tap out | 5 | text: "Take as much as you can until you have to tap out" / partner: "Control your partner's toy on maximum until they use your safe word" |
| 5 | Make your partner sleep with a plug in and send proof in the morning | Sleep with a plug and send your partner a photo when you wake up | 5 | text: "Sleep with a plug and send your partner a photo when you wake up" / partner: "Make your partner sleep with a plug in and send proof in the morning" |
| 6 | Have a session where your partner can't make a sound no matter what you do | Stay completely silent while your partner controls your toy | 5 | text: "Stay completely silent while your partner controls your toy" / partner: "Have a session where your partner can't make a sound no matter what you do" |
| 7 | Tell your partner exactly when they're allowed to finish - down to the minute | Wait until the exact moment your partner gives you permission | 5 | text: "Wait until the exact moment your partner gives you permission" / partner: "Tell your partner exactly when they're allowed to finish - down to the minute" |
| 8 | Make your partner keep a toy in while doing something mundane on video | Do a normal activity on camera while wearing a toy inside you | 4 | text: "Do a normal activity on camera while wearing a toy inside you" / partner: "Make your partner keep a toy in while doing something mundane on video" |
| 9 | Give your partner an assignment to edge a specific number of times before bed | Edge the exact number of times your partner told you to | 5 | text: "Edge the exact number of times your partner told you to" / partner: "Give your partner an assignment to edge a specific number of times before bed" |
| 10 | Control your partner's toy while they try to have a normal phone conversation with you | Try to talk normally while your partner controls your toy | 4 | text: "Try to talk normally while your partner controls your toy" / partner: "Control your partner's toy while they try to have a normal phone conversation with you" |
| 11 | Make your partner beg over text before you'll let them touch themselves | Beg your partner for permission to touch yourself | 5 | text: "Beg your partner for permission to touch yourself" / partner: "Make your partner beg over text before you'll let them touch themselves" |
| 12 | Set a time your partner must be ready and waiting on video | Be ready and waiting at the exact time your partner specified | 4 | text: "Be ready and waiting at the exact time your partner specified" / partner: "Set a time your partner must be ready and waiting on video" |
| 13 | Make your partner wear a remote toy during dinner with friends | Wear a toy at dinner while your partner teases you from afar | 5 | text: "Wear a toy at dinner while your partner teases you from afar" / partner: "Make your partner wear a remote toy during dinner with friends" |
| 14 | Have your partner send a video proving they followed your instructions | Send video evidence that you did what your partner asked | 5 | text: "Send video evidence that you did what your partner asked" / partner: "Have your partner send a video proving they followed your instructions" |
| 15 | Control your partner's toy and stop right before they finish - repeatedly | Be brought to the edge over and over without release | 5 | text: "Be brought to the edge over and over without release" / partner: "Control your partner's toy and stop right before they finish - repeatedly" |
| 16 | Make your partner use a toy on themselves while you watch and give commands | Use a toy on yourself while following your partner's instructions | 4 | text: "Use a toy on yourself while following your partner's instructions" / partner: "Make your partner use a toy on themselves while you watch and give commands" |
| 17 | Surprise your partner with a remote toy buzz during a work call | Try to keep composure while your partner surprises you mid-meeting | 4 | text: "Try to keep composure while your partner surprises you mid-meeting" / partner: "Surprise your partner with a remote toy buzz during a work call" |
| 18 | Make your partner edge themselves daily and report back to you | Edge yourself each day and send proof to your partner | 5 | text: "Edge yourself each day and send proof to your partner" / partner: "Make your partner edge themselves daily and report back to you" |
| 19 | Deny your partner for an entire day, building anticipation for the call | Go a full day without touching yourself before your partner lets you | 5 | text: "Go a full day without touching yourself before your partner lets you" / partner: "Deny your partner for an entire day, building anticipation for the call" |
| 20 | Make your partner describe every sensation while you control their toy | Narrate what you're feeling while your partner controls your toy | 4 | text: "Narrate what you're feeling while your partner controls your toy" / partner: "Make your partner describe every sensation while you control their toy" |
| 21 | Control all of your partner's pleasure for an entire video session | Give up all control and let your partner run the session | 5 | text: "Give up all control and let your partner run the session" / partner: "Control all of your partner's pleasure for an entire video session" |
| 22 | Tell your partner what to wear under their clothes for the day | Wear what your partner chose for you under your clothes | 4 | text: "Wear what your partner chose for you under your clothes" / partner: "Tell your partner what to wear under their clothes for the day" |
| 23 | Make your partner record themselves following your explicit instructions | Record yourself doing exactly what your partner tells you | 5 | text: "Record yourself doing exactly what your partner tells you" / partner: "Make your partner record themselves following your explicit instructions" |
| 24 | Count your partner's edges on video and decide when they've earned release | Count your edges out loud while your partner decides when you're done | 5 | text: "Count your edges out loud while your partner decides when you're done" / partner: "Count your partner's edges on video and decide when they've earned release" |
| 25 | Make your partner wear a plug they chose for you throughout the day | Wear a plug all day that your partner picked out | 5 | text: "Wear a plug all day that your partner picked out" / partner: "Make your partner wear a plug they chose for you throughout the day" |
| 26 | Give your partner a list of tasks to complete before your next video call | Complete tasks your partner assigns before you can call them | 5 | text: "Complete tasks your partner assigns before you can call them" / partner: "Give your partner a list of tasks to complete before your next video call" |
| 27 | Make your partner ask permission via text before they're allowed to orgasm | Ask your partner for permission before you're allowed to come | 5 | text: "Ask your partner for permission before you're allowed to come" / partner: "Make your partner ask permission via text before they're allowed to orgasm" |
| 28 | Edge your partner with a remote toy three times before letting them finish | Be edged remotely three times before your partner allows release | 5 | text: "Be edged remotely three times before your partner allows release" / partner: "Edge your partner with a remote toy three times before letting them finish" |
| 29 | Make your partner wear a remote toy while they run errands | Wear a toy in public while your partner teases you randomly | 4 | text: "Wear a toy in public while your partner teases you randomly" / partner: "Make your partner wear a remote toy while they run errands" |
| 30 | Control your partner's remote vibrator while on a video call | Wear a remote toy while your partner watches and controls it | 4 | text: "Wear a remote toy while your partner watches and controls it" / partner: "Control your partner's remote vibrator while on a video call" |
# Public Thrills (88 missing)
## 🔞 All Eyes On Us (28 missing)
| # | Current Text | Current Partner Text | Intensity | Inverse to Create |
|---|--------------|---------------------|-----------|-------------------|
| 1 | Have sex with your partner in the ocean or a lake | Have sex in the water with your partner | 5 | text: "Have sex in the water with your partner" / partner: "Have sex with your partner in the ocean or a lake" |
| 2 | Have sex on your apartment balcony | Get taken on a balcony in view of other buildings | 5 | text: "Get taken on a balcony in view of other buildings" / partner: "Have sex on your apartment balcony" |
| 3 | Have sex in a private room at a club or lounge | Sneak off to a back room and fuck at a venue | 4 | text: "Sneak off to a back room and fuck at a venue" / partner: "Have sex in a private room at a club or lounge" |
| 4 | Make your partner finish while other people are in the next room | Try not to make a sound while finishing with people nearby | 4 | text: "Try not to make a sound while finishing with people nearby" / partner: "Make your partner finish while other people are in the next room" |
| 5 | Make your partner beg for it quietly while in a public place | Beg your partner to fuck you while trying to stay quiet | 5 | text: "Beg your partner to fuck you while trying to stay quiet" / partner: "Make your partner beg for it quietly while in a public place" |
| 6 | Have sex at a beach at night when no one is around | Have sex on the sand under the stars | 5 | text: "Have sex on the sand under the stars" / partner: "Have sex at a beach at night when no one is around" |
| 7 | Have sex with your partner in front of a mirror in a semi-public place | Watch yourself have sex in a risky location | 5 | text: "Watch yourself have sex in a risky location" / partner: "Have sex with your partner in front of a mirror in a semi-public place" |
| 8 | Have sex with your partner in an Airbnb with thin walls | Have loud sex knowing the neighbors can hear | 4 | text: "Have loud sex knowing the neighbors can hear" / partner: "Have sex with your partner in an Airbnb with thin walls" |
| 9 | Have sex with your partner in a secluded part of a hiking trail | Have sex on a trail where a hiker could walk by | 5 | text: "Have sex on a trail where a hiker could walk by" / partner: "Have sex with your partner in a secluded part of a hiking trail" |
| 10 | Give your partner a handjob under a blanket on a flight | Get jerked off on a plane under a blanket | 5 | text: "Get jerked off on a plane under a blanket" / partner: "Give your partner a handjob under a blanket on a flight" |
| 11 | Have your partner go down on you in a parked car | Give road head while parked somewhere semi-private | 5 | text: "Give road head while parked somewhere semi-private" / partner: "Have your partner go down on you in a parked car" |
| 12 | Have your partner finish while sitting at a bar with a remote toy | Cum at a bar while your partner controls your toy | 5 | text: "Cum at a bar while your partner controls your toy" / partner: "Have your partner finish while sitting at a bar with a remote toy" |
| 13 | Have your partner wear a vibrator while meeting your friends | Wear a vibrator around your partner's friends while they control it | 5 | text: "Wear a vibrator around your partner's friends while they control it" / partner: "Have your partner wear a vibrator while meeting your friends" |
| 14 | Have sex in a hotel room with the curtains open at night | Get fucked with the curtains open for anyone to see | 5 | text: "Get fucked with the curtains open for anyone to see" / partner: "Have sex in a hotel room with the curtains open at night" |
| 15 | Have sex with your partner in a bathroom at a house party | Disappear at a party and come back looking suspicious | 4 | text: "Disappear at a party and come back looking suspicious" / partner: "Have sex with your partner in a bathroom at a house party" |
| 16 | Have sex with your partner in a parked car in a secluded spot | Have car sex somewhere you might get caught | 4 | text: "Have car sex somewhere you might get caught" / partner: "Have sex with your partner in a parked car in a secluded spot" |
| 17 | Make your partner moan loudly in a hotel knowing the walls are thin | Let the neighbors hear you while your partner makes you moan | 5 | text: "Let the neighbors hear you while your partner makes you moan" / partner: "Make your partner moan loudly in a hotel knowing the walls are thin" |
| 18 | Go down on your partner on a balcony at night | Receive oral on a balcony where someone could look up | 5 | text: "Receive oral on a balcony where someone could look up" / partner: "Go down on your partner on a balcony at night" |
| 19 | Make your partner finish in a semi-public bathroom | Get off in a public restroom with your partner | 4 | text: "Get off in a public restroom with your partner" / partner: "Make your partner finish in a semi-public bathroom" |
| 20 | Have sex with your partner in a hotel pool or hot tub late at night | Fool around in a hotel pool when no one else is around | 5 | text: "Fool around in a hotel pool when no one else is around" / partner: "Have sex with your partner in a hotel pool or hot tub late at night" |
| 21 | Bend your partner over in a dressing room | Get bent over in a fitting room by your partner | 5 | text: "Get bent over in a fitting room by your partner" / partner: "Bend your partner over in a dressing room" |
| 22 | Have sex with your partner against a window at night | Be taken against a window where someone could see | 5 | text: "Be taken against a window where someone could see" / partner: "Have sex with your partner against a window at night" |
| 23 | Get your partner off in the back row of an empty theater | Finish in a movie theater while your partner helps | 4 | text: "Finish in a movie theater while your partner helps" / partner: "Get your partner off in the back row of an empty theater" |
| 24 | Have your partner ride you in the driver's seat in a parking garage | Ride your partner in a parked car | 5 | text: "Ride your partner in a parked car" / partner: "Have your partner ride you in the driver's seat in a parking garage" |
| 25 | Have sex on a rooftop at night | Get fucked on a rooftop under the sky | 5 | text: "Get fucked on a rooftop under the sky" / partner: "Have sex on a rooftop at night" |
| 26 | Go down on your partner while they're on a phone call | Take a call while your partner goes down on you | 4 | text: "Take a call while your partner goes down on you" / partner: "Go down on your partner while they're on a phone call" |
| 27 | Have sex in a tent at a campsite where others might hear | Try to stay quiet while your partner fucks you in a tent | 5 | text: "Try to stay quiet while your partner fucks you in a tent" / partner: "Have sex in a tent at a campsite where others might hear" |
| 28 | Have sex in your childhood bedroom while visiting family | Fool around in your partner's family home | 4 | text: "Fool around in your partner's family home" / partner: "Have sex in your childhood bedroom while visiting family" |
## 🔞 Pushing Limits (30 missing)
| # | Current Text | Current Partner Text | Intensity | Inverse to Create |
|---|--------------|---------------------|-----------|-------------------|
| 1 | Unbutton one too many buttons on your shirt for your partner at an event | Catch your partner showing extra skin just for you | 3 | text: "Catch your partner showing extra skin just for you" / partner: "Unbutton one too many buttons on your shirt for your partner at an event" |
| 2 | Control your partner's remote toy while they order at a counter | Try to order food while your partner buzzes you | 4 | text: "Try to order food while your partner buzzes you" / partner: "Control your partner's remote toy while they order at a counter" |
| 3 | Have a quickie makeout in a hotel hallway | Make out with your partner before you even get to the room | 2 | text: "Make out with your partner before you even get to the room" / partner: "Have a quickie makeout in a hotel hallway" |
| 4 | Give your partner oral in a locked bathroom at a party | Receive oral while trying to stay quiet at an event | 5 | text: "Receive oral while trying to stay quiet at an event" / partner: "Give your partner oral in a locked bathroom at a party" |
| 5 | Make your partner wear a remote vibrator while out to dinner | Wear a vibrator at a restaurant while your partner controls it | 4 | text: "Wear a vibrator at a restaurant while your partner controls it" / partner: "Make your partner wear a remote vibrator while out to dinner" |
| 6 | Have your partner sit across from you at a bar while you describe what you'd do if you were alone | Listen to your partner describe exactly what's about to happen | 3 | text: "Listen to your partner describe exactly what's about to happen" / partner: "Have your partner sit across from you at a bar while you describe what you'd do if you were alone" |
| 7 | Press your partner against a car and kiss them before getting in | Get kissed against the car before you even leave | 3 | text: "Get kissed against the car before you even leave" / partner: "Press your partner against a car and kiss them before getting in" |
| 8 | Take your partner to a secluded spot at a park and make out | Find a hidden spot with your partner for some privacy | 3 | text: "Find a hidden spot with your partner for some privacy" / partner: "Take your partner to a secluded spot at a park and make out" |
| 9 | Kiss your partner's neck in the back of a dark venue | Feel your partner's mouth on your neck in public | 3 | text: "Feel your partner's mouth on your neck in public" / partner: "Kiss your partner's neck in the back of a dark venue" |
| 10 | Get your partner worked up in a booth at a dimly lit restaurant | Get teased in a booth where no one can see | 4 | text: "Get teased in a booth where no one can see" / partner: "Get your partner worked up in a booth at a dimly lit restaurant" |
| 11 | Make your partner sit on your lap at a bar and feel what they do to you | Sit on your partner's lap and feel their reaction | 3 | text: "Sit on your partner's lap and feel their reaction" / partner: "Make your partner sit on your lap at a bar and feel what they do to you" |
| 12 | Pull your partner into a coat closet at a party | Get pulled into a closet by your partner at an event | 4 | text: "Get pulled into a closet by your partner at an event" / partner: "Pull your partner into a coat closet at a party" |
| 13 | Touch your partner while you're both in a pool or hot tub with others nearby | Feel your partner's hands on you under the water | 4 | text: "Feel your partner's hands on you under the water" / partner: "Touch your partner while you're both in a pool or hot tub with others nearby" |
| 14 | Sneak off with your partner at a wedding for a private moment | Disappear with your partner during an event | 3 | text: "Disappear with your partner during an event" / partner: "Sneak off with your partner at a wedding for a private moment" |
| 15 | Whisper to your partner exactly what you want them to do to you once you leave | Hear in detail what's coming the moment you get home | 3 | text: "Hear in detail what's coming the moment you get home" / partner: "Whisper to your partner exactly what you want them to do to you once you leave" |
| 16 | Touch your partner through their clothes in a parked car | Get felt up in your car before going inside | 4 | text: "Get felt up in your car before going inside" / partner: "Touch your partner through their clothes in a parked car" |
| 17 | Touch your partner under the table at a dinner party until they squirm | Try not to react while your partner's hand is between your legs | 4 | text: "Try not to react while your partner's hand is between your legs" / partner: "Touch your partner under the table at a dinner party until they squirm" |
| 18 | Grind on your partner on a crowded dance floor | Feel your partner grind against you surrounded by people | 3 | text: "Feel your partner grind against you surrounded by people" / partner: "Grind on your partner on a crowded dance floor" |
| 19 | Tease your partner in the back of a cab until you get home | Get worked up in a cab knowing you can't do anything yet | 3 | text: "Get worked up in a cab knowing you can't do anything yet" / partner: "Tease your partner in the back of a cab until you get home" |
| 20 | Make out with your partner in a stairwell | Hook up with your partner in a quiet stairwell | 3 | text: "Hook up with your partner in a quiet stairwell" / partner: "Make out with your partner in a stairwell" |
| 21 | Touch your partner under a blanket at a friend's movie night | Get touched under a blanket while others are in the room | 4 | text: "Get touched under a blanket while others are in the room" / partner: "Touch your partner under a blanket at a friend's movie night" |
| 22 | Give your partner a hickey somewhere that will be hidden by clothes | Get marked by your partner in a semi-public place | 3 | text: "Get marked by your partner in a semi-public place" / partner: "Give your partner a hickey somewhere that will be hidden by clothes" |
| 23 | Slip your hand into your partner's pants briefly in a dark corner | Feel your partner's hand slide into your pants somewhere risky | 4 | text: "Feel your partner's hand slide into your pants somewhere risky" / partner: "Slip your hand into your partner's pants briefly in a dark corner" |
| 24 | Kiss your partner against a wall in an alley | Get pressed against a wall and kissed outside | 3 | text: "Get pressed against a wall and kissed outside" / partner: "Kiss your partner against a wall in an alley" |
| 25 | Touch your partner over their clothes at a house party when no one's looking | Get touched at a party while trying to act normal | 3 | text: "Get touched at a party while trying to act normal" / partner: "Touch your partner over their clothes at a house party when no one's looking" |
| 26 | Pull your partner into a dressing room for a heated makeout | Get dragged into a fitting room by your partner | 4 | text: "Get dragged into a fitting room by your partner" / partner: "Pull your partner into a dressing room for a heated makeout" |
| 27 | Get handsy with your partner in the back seat of an Uber | Fool around with your partner while someone else drives | 4 | text: "Fool around with your partner while someone else drives" / partner: "Get handsy with your partner in the back seat of an Uber" |
| 28 | Have your partner go commando on a date and tease them about it all night | Go without underwear while your partner reminds you constantly | 3 | text: "Go without underwear while your partner reminds you constantly" / partner: "Have your partner go commando on a date and tease them about it all night" |
| 29 | Touch your partner under their clothes in a dark movie theater | Feel your partner's hands under your clothes at the movies | 4 | text: "Feel your partner's hands under your clothes at the movies" / partner: "Touch your partner under their clothes in a dark movie theater" |
| 30 | Make out with your partner in a bar or club bathroom | Get pulled into a bathroom for a makeout session | 3 | text: "Get pulled into a bathroom for a makeout session" / partner: "Make out with your partner in a bar or club bathroom" |
## 🔞 Testing the Waters (30 missing)
| # | Current Text | Current Partner Text | Intensity | Inverse to Create |
|---|--------------|---------------------|-----------|-------------------|
| 1 | Graze your partner's back under their shirt while walking together | Feel your partner's fingers on your bare skin in public | 2 | text: "Feel your partner's fingers on your bare skin in public" / partner: "Graze your partner's back under their shirt while walking together" |
| 2 | Brush against your partner suggestively while passing them in a crowd | Feel your partner brush against you on purpose | 2 | text: "Feel your partner brush against you on purpose" / partner: "Brush against your partner suggestively while passing them in a crowd" |
| 3 | Leave your hand on your partner's inner thigh during a movie | Feel your partner's hand resting dangerously high during a film | 3 | text: "Feel your partner's hand resting dangerously high during a film" / partner: "Leave your hand on your partner's inner thigh during a movie" |
| 4 | Tell your partner exactly what you're going to do to them later while at a party | Hear your partner's plans for you while surrounded by people | 2 | text: "Hear your partner's plans for you while surrounded by people" / partner: "Tell your partner exactly what you're going to do to them later while at a party" |
| 5 | Make prolonged eye contact with your partner across a room until one of you breaks | Hold eye contact with your partner from across the room | 2 | text: "Hold eye contact with your partner from across the room" / partner: "Make prolonged eye contact with your partner across a room until one of you breaks" |
| 6 | Give your partner a look that tells them exactly what you're thinking | Catch your partner giving you that look in public | 2 | text: "Catch your partner giving you that look in public" / partner: "Give your partner a look that tells them exactly what you're thinking" |
| 7 | Whisper something sensual to your partner in a crowded place | Hear something sensual whispered in your ear in public | 2 | text: "Hear something sensual whispered in your ear in public" / partner: "Whisper something sensual to your partner in a crowded place" |
| 8 | Steal a long kiss with your partner in an elevator before the doors open | Make out in an elevator and stop just before it opens | 3 | text: "Make out in an elevator and stop just before it opens" / partner: "Steal a long kiss with your partner in an elevator before the doors open" |
| 9 | Run your fingers along your partner's neck while standing behind them | Feel your partner's touch on your neck in public | 2 | text: "Feel your partner's touch on your neck in public" / partner: "Run your fingers along your partner's neck while standing behind them" |
| 10 | Press against your partner from behind while waiting in line | Feel your partner pressed against you in a queue | 3 | text: "Feel your partner pressed against you in a queue" / partner: "Press against your partner from behind while waiting in line" |
| 11 | Kiss your partner's neck briefly while hugging them goodbye in front of others | Feel your partner's lips on your neck in a public goodbye | 2 | text: "Feel your partner's lips on your neck in a public goodbye" / partner: "Kiss your partner's neck briefly while hugging them goodbye in front of others" |
| 12 | Flirt with your partner like you just met them at a bar | Play along while your partner hits on you like a stranger | 2 | text: "Play along while your partner hits on you like a stranger" / partner: "Flirt with your partner like you just met them at a bar" |
| 13 | Bite your lip while looking at your partner across a table full of people | Catch your partner biting their lip at you in front of others | 2 | text: "Catch your partner biting their lip at you in front of others" / partner: "Bite your lip while looking at your partner across a table full of people" |
| 14 | Slip your hand into your partner's back pocket while walking | Feel your partner's hand slide into your pocket | 2 | text: "Feel your partner's hand slide into your pocket" / partner: "Slip your hand into your partner's back pocket while walking" |
| 15 | Tell your partner to meet you somewhere private in five minutes | Get a message to meet your partner somewhere right now | 3 | text: "Get a message to meet your partner somewhere right now" / partner: "Tell your partner to meet you somewhere private in five minutes" |
| 16 | Drag your nails lightly down your partner's arm while talking to friends | Feel your partner's nails on your skin while acting normal | 2 | text: "Feel your partner's nails on your skin while acting normal" / partner: "Drag your nails lightly down your partner's arm while talking to friends" |
| 17 | Lean in and smell your partner while complimenting them in public | Have your partner lean in close and breathe you in | 2 | text: "Have your partner lean in close and breathe you in" / partner: "Lean in and smell your partner while complimenting them in public" |
| 18 | Dance closely with your partner at a club like no one else exists | Get lost in your partner on the dance floor | 3 | text: "Get lost in your partner on the dance floor" / partner: "Dance closely with your partner at a club like no one else exists" |
| 19 | Pin your partner against a wall for a quick kiss when no one's watching | Get pinned and kissed somewhere semi-public | 3 | text: "Get pinned and kissed somewhere semi-public" / partner: "Pin your partner against a wall for a quick kiss when no one's watching" |
| 20 | Trace shapes on your partner's palm under the table | Feel your partner's fingers drawing on your hand secretly | 2 | text: "Feel your partner's fingers drawing on your hand secretly" / partner: "Trace shapes on your partner's palm under the table" |
| 21 | Whisper to your partner that you're skipping underwear today | Discover your partner is skipping underwear while you're out | 3 | text: "Discover your partner is skipping underwear while you're out" / partner: "Whisper to your partner that you're skipping underwear today" |
| 22 | Send your partner a suggestive photo while at an event | Receive a suggestive photo from your partner in public | 3 | text: "Receive a suggestive photo from your partner in public" / partner: "Send your partner a suggestive photo while at an event" |
| 23 | Give your partner's backside a playful squeeze | Have your partner playfully squeeze your backside | 2 | text: "Have your partner playfully squeeze your backside" / partner: "Give your partner's backside a playful squeeze" |
| 24 | Whisper a romantic fantasy to your partner at a gathering | Hear a romantic fantasy whispered to you in a crowd | 2 | text: "Hear a romantic fantasy whispered to you in a crowd" / partner: "Whisper a romantic fantasy to your partner at a gathering" |
| 25 | Play footsie with your partner under the table | Feel your partner's foot teasing yours under the table | 2 | text: "Feel your partner's foot teasing yours under the table" / partner: "Play footsie with your partner under the table" |
| 26 | Text your partner your desires while sitting across from them | Receive a suggestive text from your partner while looking at them | 2 | text: "Receive a suggestive text from your partner while looking at them" / partner: "Text your partner your desires while sitting across from them" |
| 27 | Kiss your partner somewhere unexpected while out running errands | Get kissed by your partner when you least expect it in public | 2 | text: "Get kissed by your partner when you least expect it in public" / partner: "Kiss your partner somewhere unexpected while out running errands" |
| 28 | Rest your hand on your partner's upper thigh at dinner | Feel your partner's hand creep up your thigh at a restaurant | 2 | text: "Feel your partner's hand creep up your thigh at a restaurant" / partner: "Rest your hand on your partner's upper thigh at dinner" |
| 29 | Hold your partner's hand under the table while making intense eye contact | Lock eyes with your partner while they hold your hand secretly | 2 | text: "Lock eyes with your partner while they hold your hand secretly" / partner: "Hold your partner's hand under the table while making intense eye contact" |
| 30 | Pull your partner into a quick hidden kiss in a store aisle | Get pulled into a secret kiss while shopping | 2 | text: "Get pulled into a secret kiss while shopping" / partner: "Pull your partner into a quick hidden kiss in a store aisle" |
# Romance & Sensuality (103 missing)
## 🔞 Date Night Sparks (7 missing)
| # | Current Text | Current Partner Text | Intensity | Inverse to Create |
|---|--------------|---------------------|-----------|-------------------|
| 1 | Give your partner a sensual massage that leads to more | Receive a sensual massage that leads to more | 3 | text: "Receive a sensual massage that leads to more" / partner: "Give your partner a sensual massage that leads to more" |
| 2 | Give your partner a lap dance | Receive a lap dance from your partner | 3 | text: "Receive a lap dance from your partner" / partner: "Give your partner a lap dance" |
| 3 | Go to dinner and whisper what you want to do later | Hear what your partner wants to do to you later | 3 | text: "Hear what your partner wants to do to you later" / partner: "Go to dinner and whisper what you want to do later" |
| 4 | Give your partner a foot massage that travels up their leg | Receive a foot massage that travels up your leg | 3 | text: "Receive a foot massage that travels up your leg" / partner: "Give your partner a foot massage that travels up their leg" |
| 5 | Wear something your partner picks out for date night | Pick out what your partner wears for date night | 2 | text: "Pick out what your partner wears for date night" / partner: "Wear something your partner picks out for date night" |
| 6 | Give your partner a striptease | Watch your partner do a striptease for you | 3 | text: "Watch your partner do a striptease for you" / partner: "Give your partner a striptease" |
| 7 | Plan a surprise date night ending with intimacy | Be surprised with a date night ending in intimacy | 3 | text: "Be surprised with a date night ending in intimacy" / partner: "Plan a surprise date night ending with intimacy" |
## 🔞 Love Notes (34 missing)
| # | Current Text | Current Partner Text | Intensity | Inverse to Create |
|---|--------------|---------------------|-----------|-------------------|
| 1 | Send a 'thinking about you' text describing exactly what you're thinking | Receive a detailed 'thinking about you' text | 3 | text: "Receive a detailed 'thinking about you' text" / partner: "Send a 'thinking about you' text describing exactly what you're thinking" |
| 2 | Write your partner a note about the first time you wanted them | Read about when your partner first wanted you | 2 | text: "Read about when your partner first wanted you" / partner: "Write your partner a note about the first time you wanted them" |
| 3 | Send a flirty emoji-only message and have them guess what it means | Decode a flirty emoji message from your partner | 2 | text: "Decode a flirty emoji message from your partner" / partner: "Send a flirty emoji-only message and have them guess what it means" |
| 4 | Create a scavenger hunt of notes leading to the bedroom | Follow a scavenger hunt of notes to the bedroom | 3 | text: "Follow a scavenger hunt of notes to the bedroom" / partner: "Create a scavenger hunt of notes leading to the bedroom" |
| 5 | Text your partner during work about the lingerie you're wearing | Receive a text about what your partner is wearing underneath | 3 | text: "Receive a text about what your partner is wearing underneath" / partner: "Text your partner during work about the lingerie you're wearing" |
| 6 | Write down what you want your partner to do to you and leave it on their pillow | Find instructions on your pillow from your partner | 4 | text: "Find instructions on your pillow from your partner" / partner: "Write down what you want your partner to do to you and leave it on their pillow" |
| 7 | Send your partner a playlist of songs that make you think of them sexually | Listen to a sexy playlist your partner made for you | 2 | text: "Listen to a sexy playlist your partner made for you" / partner: "Send your partner a playlist of songs that make you think of them sexually" |
| 8 | Leave a note in your partner's pocket or bag with tonight's plans | Find a surprise note about tonight's plans | 2 | text: "Find a surprise note about tonight's plans" / partner: "Leave a note in your partner's pocket or bag with tonight's plans" |
| 9 | Write a poem about your physical attraction to your partner | Receive a poem about your partner's attraction to you | 2 | text: "Receive a poem about your partner's attraction to you" / partner: "Write a poem about your physical attraction to your partner" |
| 10 | Send dirty talk messages throughout the day to build anticipation | Receive dirty talk messages all day | 4 | text: "Receive dirty talk messages all day" / partner: "Send dirty talk messages throughout the day to build anticipation" |
| 11 | Create a 'sexy coupon book' for your partner | Receive a sexy coupon book from your partner | 3 | text: "Receive a sexy coupon book from your partner" / partner: "Create a 'sexy coupon book' for your partner" |
| 12 | Write down a secret fantasy and give it to your partner | Receive and read your partner's secret fantasy | 3 | text: "Receive and read your partner's secret fantasy" / partner: "Write down a secret fantasy and give it to your partner" |
| 13 | Send your partner voice messages describing your desires | Receive voice messages describing your partner's desires | 3 | text: "Receive voice messages describing your partner's desires" / partner: "Send your partner voice messages describing your desires" |
| 14 | Write a list of places you want to have sex and share it | Receive a list of places your partner wants to have sex | 4 | text: "Receive a list of places your partner wants to have sex" / partner: "Write a list of places you want to have sex and share it" |
| 15 | Send your partner a countdown of sexy things you'll do when you see them | Receive a sexy countdown from your partner | 3 | text: "Receive a sexy countdown from your partner" / partner: "Send your partner a countdown of sexy things you'll do when you see them" |
| 16 | Leave a note on the bathroom mirror describing your favorite intimate moment | Find a note about your partner's favorite intimate moment | 2 | text: "Find a note about your partner's favorite intimate moment" / partner: "Leave a note on the bathroom mirror describing your favorite intimate moment" |
| 17 | Write a steamy story starring you and your partner | Read a steamy story your partner wrote about you both | 3 | text: "Read a steamy story your partner wrote about you both" / partner: "Write a steamy story starring you and your partner" |
| 18 | Send your partner a suggestive photo during the day | Receive a suggestive photo from your partner | 3 | text: "Receive a suggestive photo from your partner" / partner: "Send your partner a suggestive photo during the day" |
| 19 | Write down three things you love about your partner's body | Read three things your partner loves about your body | 2 | text: "Read three things your partner loves about your body" / partner: "Write down three things you love about your partner's body" |
| 20 | Text your partner a fantasy you want to try | Receive a fantasy text from your partner | 3 | text: "Receive a fantasy text from your partner" / partner: "Text your partner a fantasy you want to try" |
| 21 | Leave a flirty note hidden for your partner to find | Find a flirty hidden note from your partner | 2 | text: "Find a flirty hidden note from your partner" / partner: "Leave a flirty note hidden for your partner to find" |
| 22 | Send your partner a sexy text describing what you want to do tonight | Receive a sexy text about tonight | 3 | text: "Receive a sexy text about tonight" / partner: "Send your partner a sexy text describing what you want to do tonight" |
| 23 | Write your partner a love letter describing what turns you on about them | Receive a love letter about what turns your partner on | 2 | text: "Receive a love letter about what turns your partner on" / partner: "Write your partner a love letter describing what turns you on about them" |
| 24 | Write down a new position you want to try and slip it to your partner | Receive a suggestion for a new position to try | 4 | text: "Receive a suggestion for a new position to try" / partner: "Write down a new position you want to try and slip it to your partner" |
| 25 | Send your partner a teasing text and make them wait for the follow-up | Receive a teasing text with anticipation | 3 | text: "Receive a teasing text with anticipation" / partner: "Send your partner a teasing text and make them wait for the follow-up" |
| 26 | Write your partner a note about what you'd do if they walked in right now | Find a note about what would happen if you walked in | 3 | text: "Find a note about what would happen if you walked in" / partner: "Write your partner a note about what you'd do if they walked in right now" |
| 27 | Send a 'can't wait to see you' text with explicit details | Receive an explicit 'can't wait' text | 4 | text: "Receive an explicit 'can't wait' text" / partner: "Send a 'can't wait to see you' text with explicit details" |
| 28 | Write down your top 5 favorite things your partner does in bed | Read your partner's top 5 favorite things you do in bed | 3 | text: "Read your partner's top 5 favorite things you do in bed" / partner: "Write down your top 5 favorite things your partner does in bed" |
| 29 | Send your partner a gif or meme that expresses how much you want them | Receive a flirty gif from your partner | 2 | text: "Receive a flirty gif from your partner" / partner: "Send your partner a gif or meme that expresses how much you want them" |
| 30 | Write a note thanking your partner for an amazing sexual experience | Receive a thank-you note for an amazing experience | 2 | text: "Receive a thank-you note for an amazing experience" / partner: "Write a note thanking your partner for an amazing sexual experience" |
| 31 | Send your partner a 'remember when we...' text about a hot memory | Reminisce about a hot memory via text | 3 | text: "Reminisce about a hot memory via text" / partner: "Send your partner a 'remember when we...' text about a hot memory" |
| 32 | Leave a note with one word describing what you want tonight | Find a one-word hint about tonight | 3 | text: "Find a one-word hint about tonight" / partner: "Leave a note with one word describing what you want tonight" |
| 33 | Write a letter about how your partner makes you feel desired | Receive a letter about how you make your partner feel desired | 2 | text: "Receive a letter about how you make your partner feel desired" / partner: "Write a letter about how your partner makes you feel desired" |
| 34 | Send your partner a 'this made me think of you' message with something sexy | Receive a sexy 'this made me think of you' message | 2 | text: "Receive a sexy 'this made me think of you' message" / partner: "Send your partner a 'this made me think of you' message with something sexy" |
## ✨ Romantic Gestures (28 missing)
| # | Current Text | Current Partner Text | Intensity | Inverse to Create |
|---|--------------|---------------------|-----------|-------------------|
| 1 | Wash your partner in a warm bath | Be washed by your partner in a warm bath | 2 | text: "Be washed by your partner in a warm bath" / partner: "Wash your partner in a warm bath" |
| 2 | Greet your partner wearing only their favorite outfit or nothing | Be greeted by your partner wearing only your favorite outfit or nothing | 3 | text: "Be greeted by your partner wearing only your favorite outfit or nothing" / partner: "Greet your partner wearing only their favorite outfit or nothing" |
| 3 | Trace your fingers along your partner's body | Let your partner trace their fingers along your body | 2 | text: "Let your partner trace their fingers along your body" / partner: "Trace your fingers along your partner's body" |
| 4 | Surprise your partner with a passionate kiss | Be surprised with a passionate kiss | 2 | text: "Be surprised with a passionate kiss" / partner: "Surprise your partner with a passionate kiss" |
| 5 | Carry your partner to bed and worship their body | Be carried to bed and have your body worshipped | 3 | text: "Be carried to bed and have your body worshipped" / partner: "Carry your partner to bed and worship their body" |
| 6 | Slowly kiss from your partner's lips down to their collarbone | Be kissed from your lips down to your collarbone | 3 | text: "Be kissed from your lips down to your collarbone" / partner: "Slowly kiss from your partner's lips down to their collarbone" |
| 7 | Kiss your partner's forehead and tell them they're safe with you | Be kissed on the forehead and told you're safe | 2 | text: "Be kissed on the forehead and told you're safe" / partner: "Kiss your partner's forehead and tell them they're safe with you" |
| 8 | Pull your partner onto your lap and kiss them | Be pulled onto your partner's lap | 3 | text: "Be pulled onto your partner's lap" / partner: "Pull your partner onto your lap and kiss them" |
| 9 | Tell your partner you find them sexy while doing something mundane | Be told you're sexy during something mundane | 2 | text: "Be told you're sexy during something mundane" / partner: "Tell your partner you find them sexy while doing something mundane" |
| 10 | Cup your partner's face and kiss them deeply before saying goodbye | Be kissed deeply before your partner leaves | 2 | text: "Be kissed deeply before your partner leaves" / partner: "Cup your partner's face and kiss them deeply before saying goodbye" |
| 11 | Take your partner's hand and lead them to bed wordlessly | Be led to bed wordlessly | 3 | text: "Be led to bed wordlessly" / partner: "Take your partner's hand and lead them to bed wordlessly" |
| 12 | Hold your partner from behind and kiss their neck while they do something | Be held from behind and kissed on the neck | 3 | text: "Be held from behind and kissed on the neck" / partner: "Hold your partner from behind and kiss their neck while they do something" |
| 13 | Watch your partner get dressed and tell them how attractive they are | Be watched getting dressed and complimented | 2 | text: "Be watched getting dressed and complimented" / partner: "Watch your partner get dressed and tell them how attractive they are" |
| 14 | Give your partner a full body massage ending wherever they want | Receive a full body massage that ends wherever you want | 3 | text: "Receive a full body massage that ends wherever you want" / partner: "Give your partner a full body massage ending wherever they want" |
| 15 | Brush your partner's hair and tell them how beautiful they are | Have your partner brush your hair and compliment you | 2 | text: "Have your partner brush your hair and compliment you" / partner: "Brush your partner's hair and tell them how beautiful they are" |
| 16 | Slowly apply lotion to your partner's whole body | Have lotion applied slowly to your whole body | 3 | text: "Have lotion applied slowly to your whole body" / partner: "Slowly apply lotion to your partner's whole body" |
| 17 | Run a warm washcloth over your partner after intimacy | Be cared for after intimacy with a warm washcloth | 2 | text: "Be cared for after intimacy with a warm washcloth" / partner: "Run a warm washcloth over your partner after intimacy" |
| 18 | Seduce your partner in a dimly lit room | Be seduced in a dimly lit room | 3 | text: "Be seduced in a dimly lit room" / partner: "Seduce your partner in a dimly lit room" |
| 19 | Serve your partner breakfast in bed while naked | Be served breakfast in bed by your naked partner | 3 | text: "Be served breakfast in bed by your naked partner" / partner: "Serve your partner breakfast in bed while naked" |
| 20 | Lead your partner to the bedroom for a surprise | Be led to the bedroom for a surprise | 2 | text: "Be led to the bedroom for a surprise" / partner: "Lead your partner to the bedroom for a surprise" |
| 21 | Pull your partner close unexpectedly and tell them you want them | Be pulled close and told you're wanted | 3 | text: "Be pulled close and told you're wanted" / partner: "Pull your partner close unexpectedly and tell them you want them" |
| 22 | Give your partner a lingering kiss on the neck | Receive a lingering kiss on your neck | 3 | text: "Receive a lingering kiss on your neck" / partner: "Give your partner a lingering kiss on the neck" |
| 23 | Run your fingers through your partner's hair while they rest on you | Have your hair played with while resting on your partner | 2 | text: "Have your hair played with while resting on your partner" / partner: "Run your fingers through your partner's hair while they rest on you" |
| 24 | Hold your partner's face and tell them what you love about them before kissing | Have your partner hold your face and shower you with love | 2 | text: "Have your partner hold your face and shower you with love" / partner: "Hold your partner's face and tell them what you love about them before kissing" |
| 25 | Wake your partner up with kisses all over their body | Be woken up with kisses all over your body | 3 | text: "Be woken up with kisses all over your body" / partner: "Wake your partner up with kisses all over their body" |
| 26 | Kiss every part of your partner's body | Have every part of your body kissed | 3 | text: "Have every part of your body kissed" / partner: "Kiss every part of your partner's body" |
| 27 | Undress your partner slowly while maintaining eye contact | Be slowly undressed while looking into your partner's eyes | 3 | text: "Be slowly undressed while looking into your partner's eyes" / partner: "Undress your partner slowly while maintaining eye contact" |
| 28 | Whisper something sexy in your partner's ear in public | Have your partner whisper something sexy in your ear | 3 | text: "Have your partner whisper something sexy in your ear" / partner: "Whisper something sexy in your partner's ear in public" |
## 🔞 Sensual Touch (34 missing)
| # | Current Text | Current Partner Text | Intensity | Inverse to Create |
|---|--------------|---------------------|-----------|-------------------|
| 1 | Breathe warmly on your partner's neck before kissing it | Feel warm breath on your neck before being kissed | 3 | text: "Feel warm breath on your neck before being kissed" / partner: "Breathe warmly on your partner's neck before kissing it" |
| 2 | Use silk or satin fabric to touch your partner's skin | Be touched with silk or satin on your skin | 3 | text: "Be touched with silk or satin on your skin" / partner: "Use silk or satin fabric to touch your partner's skin" |
| 3 | Give your partner a foot massage with intention | Receive an intentional foot massage | 2 | text: "Receive an intentional foot massage" / partner: "Give your partner a foot massage with intention" |
| 4 | Lightly scratch your partner's back during intimacy | Have your back scratched during intimacy | 3 | text: "Have your back scratched during intimacy" / partner: "Lightly scratch your partner's back during intimacy" |
| 5 | Use your lips to explore your partner's body without using hands | Be explored with only your partner's lips | 3 | text: "Be explored with only your partner's lips" / partner: "Use your lips to explore your partner's body without using hands" |
| 6 | Trace your fingernails lightly down your partner's back | Have fingernails traced lightly down your back | 3 | text: "Have fingernails traced lightly down your back" / partner: "Trace your fingernails lightly down your partner's back" |
| 7 | Tease your partner everywhere except where they want it most | Be teased everywhere except where you want it most | 4 | text: "Be teased everywhere except where you want it most" / partner: "Tease your partner everywhere except where they want it most" |
| 8 | Massage your partner's inner thighs without going further | Have your inner thighs massaged without going further | 3 | text: "Have your inner thighs massaged without going further" / partner: "Massage your partner's inner thighs without going further" |
| 9 | Suck and lick your partner's fingers | Have your fingers sucked and licked | 3 | text: "Have your fingers sucked and licked" / partner: "Suck and lick your partner's fingers" |
| 10 | Touch your partner and have them rate the sensation | Let your partner touch you and rate the sensation | 3 | text: "Let your partner touch you and rate the sensation" / partner: "Touch your partner and have them rate the sensation" |
| 11 | Dry hump your partner slowly | Be dry humped slowly by your partner | 3 | text: "Be dry humped slowly by your partner" / partner: "Dry hump your partner slowly" |
| 12 | Kiss and nibble your partner's ears | Have your ears kissed and nibbled | 3 | text: "Have your ears kissed and nibbled" / partner: "Kiss and nibble your partner's ears" |
| 13 | Use warming or cooling massage oil on your partner | Experience warming or cooling massage oil | 3 | text: "Experience warming or cooling massage oil" / partner: "Use warming or cooling massage oil on your partner" |
| 14 | Give your partner a full body massage with warm oil | Receive a warm oil full body massage | 3 | text: "Receive a warm oil full body massage" / partner: "Give your partner a full body massage with warm oil" |
| 15 | Blindfold your partner and use different textures on their body | Be blindfolded and touched with different textures | 3 | text: "Be blindfolded and touched with different textures" / partner: "Blindfold your partner and use different textures on their body" |
| 16 | Run ice cubes along your partner's body | Have ice cubes run along your body | 3 | text: "Have ice cubes run along your body" / partner: "Run ice cubes along your partner's body" |
| 17 | Use light touch to give your partner shivers | Let your partner give you shivers with light touch | 3 | text: "Let your partner give you shivers with light touch" / partner: "Use light touch to give your partner shivers" |
| 18 | Explore your partner's body and discover new sensitive spots | Have your body explored for new sensitive spots | 3 | text: "Have your body explored for new sensitive spots" / partner: "Explore your partner's body and discover new sensitive spots" |
| 19 | Blindfold your partner and make them guess what you're touching them with | Guess what you're being touched with while blindfolded | 3 | text: "Guess what you're being touched with while blindfolded" / partner: "Blindfold your partner and make them guess what you're touching them with" |
| 20 | Use a feather to tease your partner all over | Be teased with a feather all over | 3 | text: "Be teased with a feather all over" / partner: "Use a feather to tease your partner all over" |
| 21 | Use your breath to stimulate your partner without touching | Be stimulated by your partner's breath without touch | 3 | text: "Be stimulated by your partner's breath without touch" / partner: "Use your breath to stimulate your partner without touching" |
| 22 | Caress your partner's face while looking into their eyes | Have your face caressed while looking into your partner's eyes | 2 | text: "Have your face caressed while looking into your partner's eyes" / partner: "Caress your partner's face while looking into their eyes" |
| 23 | Massage your partner's shoulders while they sit between your legs | Sit between your partner's legs while they massage you | 2 | text: "Sit between your partner's legs while they massage you" / partner: "Massage your partner's shoulders while they sit between your legs" |
| 24 | Use edible body paint on your partner | Have edible body paint used on you | 4 | text: "Have edible body paint used on you" / partner: "Use edible body paint on your partner" |
| 25 | Give your partner goosebumps by whispering and touching | Get goosebumps from whispers and touch | 3 | text: "Get goosebumps from whispers and touch" / partner: "Give your partner goosebumps by whispering and touching" |
| 26 | Alternate between firm and gentle touches on your partner's body | Experience alternating firm and gentle touches | 3 | text: "Experience alternating firm and gentle touches" / partner: "Alternate between firm and gentle touches on your partner's body" |
| 27 | Touch your partner through their clothes before undressing them | Be touched through your clothes before being undressed | 3 | text: "Be touched through your clothes before being undressed" / partner: "Touch your partner through their clothes before undressing them" |
| 28 | Run your hands through your partner's hair and grip gently | Have your hair played with and gripped gently | 3 | text: "Have your hair played with and gripped gently" / partner: "Run your hands through your partner's hair and grip gently" |
| 29 | Kiss the inside of your partner's wrist | Have the inside of your wrist kissed | 2 | text: "Have the inside of your wrist kissed" / partner: "Kiss the inside of your partner's wrist" |
| 30 | Use a massage candle on your partner's body | Have warm massage candle wax used on your body | 3 | text: "Have warm massage candle wax used on your body" / partner: "Use a massage candle on your partner's body" |
| 31 | Apply lotion to your partner slowly and sensually | Have lotion applied to you slowly | 3 | text: "Have lotion applied to you slowly" / partner: "Apply lotion to your partner slowly and sensually" |
| 32 | Use your tongue to trace patterns on your partner's skin | Have patterns traced on your skin with a tongue | 4 | text: "Have patterns traced on your skin with a tongue" / partner: "Use your tongue to trace patterns on your partner's skin" |
| 33 | Give your partner a scalp massage that turns sensual | Receive a scalp massage that turns sensual | 2 | text: "Receive a scalp massage that turns sensual" / partner: "Give your partner a scalp massage that turns sensual" |
| 34 | Hold your partner's hands above their head while kissing them | Have your hands held above your head while being kissed | 3 | text: "Have your hands held above your head while being kissed" / partner: "Hold your partner's hands above their head while kissing them" |
# Spicy Challenges (56 missing)
## 🔞 Dare to Play (22 missing)
| # | Current Text | Current Partner Text | Intensity | Inverse to Create |
|---|--------------|---------------------|-----------|-------------------|
| 1 | Perform a striptease for your partner | Watch your partner do a striptease | 3 | text: "Watch your partner do a striptease" / partner: "Perform a striptease for your partner" |
| 2 | Use only your mouth to pleasure your partner | Be pleasured using only your partner's mouth | 4 | text: "Be pleasured using only your partner's mouth" / partner: "Use only your mouth to pleasure your partner" |
| 3 | Make your partner orgasm twice in one session | Have your partner make you orgasm twice | 5 | text: "Have your partner make you orgasm twice" / partner: "Make your partner orgasm twice in one session" |
| 4 | Initiate sex at an unexpected time | Be surprised by your partner initiating sex | 4 | text: "Be surprised by your partner initiating sex" / partner: "Initiate sex at an unexpected time" |
| 5 | Make your partner beg for what they want | Let your partner make you beg for what you want | 4 | text: "Let your partner make you beg for what you want" / partner: "Make your partner beg for what they want" |
| 6 | Tell your partner your dirtiest thought | Hear your partner's dirtiest thought | 3 | text: "Hear your partner's dirtiest thought" / partner: "Tell your partner your dirtiest thought" |
| 7 | Send your partner a nude photo unexpectedly | Receive a nude photo from your partner | 4 | text: "Receive a nude photo from your partner" / partner: "Send your partner a nude photo unexpectedly " |
| 8 | Surprise your partner with spontaneous oral during a mundane activity | Be surprised with spontaneous oral | 4 | text: "Be surprised with spontaneous oral" / partner: "Surprise your partner with spontaneous oral during a mundane activity" |
| 9 | Blindfold your partner and pleasure them | Be blindfolded and pleasured | 4 | text: "Be blindfolded and pleasured" / partner: "Blindfold your partner and pleasure them" |
| 10 | Let your partner do whatever they want to you for 15 minutes | Do whatever you want to your partner for 15 minutes | 5 | text: "Do whatever you want to your partner for 15 minutes" / partner: "Let your partner do whatever they want to you for 15 minutes" |
| 11 | Describe exactly what you're going to do to your partner, then do it | Listen to what your partner will do, then experience it | 4 | text: "Listen to what your partner will do, then experience it" / partner: "Describe exactly what you're going to do to your partner, then do it" |
| 12 | Make your partner cum without using penetration | Have your partner make you cum without penetration | 4 | text: "Have your partner make you cum without penetration" / partner: "Make your partner cum without using penetration" |
| 13 | Wake your partner up with oral sex | Be woken up with oral sex | 4 | text: "Be woken up with oral sex" / partner: "Wake your partner up with oral sex" |
| 14 | Take control and tell your partner exactly what to do | Let your partner take full control | 4 | text: "Let your partner take full control" / partner: "Take control and tell your partner exactly what to do" |
| 15 | Give your partner a slow, teasing handjob or fingering session | Receive a slow, teasing handjob or fingering | 4 | text: "Receive a slow, teasing handjob or fingering" / partner: "Give your partner a slow, teasing handjob or fingering session" |
| 16 | Edge your partner three times before letting them finish | Be edged three times before finishing | 4 | text: "Be edged three times before finishing" / partner: "Edge your partner three times before letting them finish" |
| 17 | Go down on your partner for at least 10 minutes | Receive oral for at least 10 minutes | 4 | text: "Receive oral for at least 10 minutes" / partner: "Go down on your partner for at least 10 minutes" |
| 18 | Give your partner a lap dance in lingerie or underwear | Receive a lap dance | 3 | text: "Receive a lap dance" / partner: "Give your partner a lap dance in lingerie or underwear" |
| 19 | Masturbate in front of your partner | Watch your partner masturbate | 4 | text: "Watch your partner masturbate" / partner: "Masturbate in front of your partner" |
| 20 | Tease your partner with texts all day, then deliver | Be teased with texts then get the payoff | 4 | text: "Be teased with texts then get the payoff" / partner: "Tease your partner with texts all day, then deliver" |
| 21 | Make your partner come using only external stimulation | Be brought to orgasm without any penetration | 3 | text: "Be brought to orgasm without any penetration" / partner: "Make your partner come using only external stimulation" |
| 22 | Be brought to orgasm without any penetration | Make your partner cum using only external stimulation | 3 | text: "Make your partner cum using only external stimulation" / partner: "Be brought to orgasm without any penetration" |
## 🔞 Pleasure Experiments (1 missing)
| # | Current Text | Current Partner Text | Intensity | Inverse to Create |
|---|--------------|---------------------|-----------|-------------------|
| 1 | Slowly run an ice cube down your partner's body | Let your partner trace an ice cube down your body | 2 | text: "Let your partner trace an ice cube down your body" / partner: "Slowly run an ice cube down your partner's body" |
## 🔞 Tease & Tension (33 missing)
| # | Current Text | Current Partner Text | Intensity | Inverse to Create |
|---|--------------|---------------------|-----------|-------------------|
| 1 | Strip slowly in front of your partner but don't let them touch | Watch your partner strip without being able to touch | 3 | text: "Watch your partner strip without being able to touch" / partner: "Strip slowly in front of your partner but don't let them touch" |
| 2 | Whisper what you want to do later in explicit detail | Listen to explicit details of what's coming later | 3 | text: "Listen to explicit details of what's coming later" / partner: "Whisper what you want to do later in explicit detail" |
| 3 | Restrain your partner and take your time with them | Be restrained while your partner takes their time | 4 | text: "Be restrained while your partner takes their time" / partner: "Restrain your partner and take your time with them" |
| 4 | Use ice on your partner's body while they're restrained | Have ice used on you while restrained | 5 | text: "Have ice used on you while restrained" / partner: "Use ice on your partner's body while they're restrained" |
| 5 | Use a vibrator on your partner on the lowest setting | Be teased with a vibrator on the lowest setting | 4 | text: "Be teased with a vibrator on the lowest setting" / partner: "Use a vibrator on your partner on the lowest setting" |
| 6 | Blindfold your partner and make them guess what comes next | Be blindfolded and guess what's coming | 4 | text: "Be blindfolded and guess what's coming" / partner: "Blindfold your partner and make them guess what comes next" |
| 7 | Maintain eye contact while touching your partner slowly | Maintain eye contact while being touched slowly | 3 | text: "Maintain eye contact while being touched slowly" / partner: "Maintain eye contact while touching your partner slowly" |
| 8 | Make your partner beg before you touch them where they want | Let your partner make you beg before being touched | 4 | text: "Let your partner make you beg before being touched" / partner: "Make your partner beg before you touch them where they want" |
| 9 | Deny your partner an orgasm until they can't take it anymore | Be denied orgasm until you can't take it anymore | 5 | text: "Be denied orgasm until you can't take it anymore" / partner: "Deny your partner an orgasm until they can't take it anymore" |
| 10 | Give your partner a massage that 'accidentally' gets too close | Receive a massage that keeps getting close | 3 | text: "Receive a massage that keeps getting close" / partner: "Give your partner a massage that 'accidentally' gets too close" |
| 11 | Whisper dirty things in your partner's ear without touching them | Hear dirty things whispered without being touched | 3 | text: "Hear dirty things whispered without being touched" / partner: "Whisper dirty things in your partner's ear without touching them" |
| 12 | Bring your partner to the edge and stop - repeat three times | Be brought to the edge and stopped three times | 4 | text: "Be brought to the edge and stopped three times" / partner: "Bring your partner to the edge and stop - repeat three times" |
| 13 | Give your partner a lap dance without letting them touch | Receive a lap dance without being allowed to touch | 3 | text: "Receive a lap dance without being allowed to touch" / partner: "Give your partner a lap dance without letting them touch" |
| 14 | Send your partner flirty texts all day building anticipation | Receive flirty texts all day building anticipation | 2 | text: "Receive flirty texts all day building anticipation" / partner: "Send your partner flirty texts all day building anticipation" |
| 15 | Touch your partner everywhere except where they want most | Be touched everywhere except where you want most | 4 | text: "Be touched everywhere except where you want most" / partner: "Touch your partner everywhere except where they want most" |
| 16 | Use feathers or soft textures to tease all over | Be teased all over with feathers or soft textures | 3 | text: "Be teased all over with feathers or soft textures" / partner: "Use feathers or soft textures to tease all over" |
| 17 | Send a countdown of hours until you'll have sex, with teasers | Receive a countdown with teasing updates | 2 | text: "Receive a countdown with teasing updates" / partner: "Send a countdown of hours until you'll have sex, with teasers" |
| 18 | Undress your partner one piece at a time over an hour | Be undressed one piece at a time over an hour | 3 | text: "Be undressed one piece at a time over an hour" / partner: "Undress your partner one piece at a time over an hour" |
| 19 | Get your partner worked up then leave the room for 5 minutes | Be worked up then left waiting for 5 minutes | 4 | text: "Be worked up then left waiting for 5 minutes" / partner: "Get your partner worked up then leave the room for 5 minutes" |
| 20 | Tease your partner during an entire movie and only finish after | Be teased during a whole movie | 4 | text: "Be teased during a whole movie" / partner: "Tease your partner during an entire movie and only finish after" |
| 21 | Tell your partner exactly what you want but make them wait | Hear what's coming but have to wait | 3 | text: "Hear what's coming but have to wait" / partner: "Tell your partner exactly what you want but make them wait" |
| 22 | Touch yourself in front of your partner but don't let them join | Watch your partner but not be allowed to join | 4 | text: "Watch your partner but not be allowed to join" / partner: "Touch yourself in front of your partner but don't let them join" |
| 23 | Hover your body over your partner without making contact | Have your partner hover without making contact | 3 | text: "Have your partner hover without making contact" / partner: "Hover your body over your partner without making contact" |
| 24 | Kiss every part of your partner except their lips | Be kissed everywhere except your lips | 3 | text: "Be kissed everywhere except your lips" / partner: "Kiss every part of your partner except their lips" |
| 25 | Give oral for 30 seconds, stop, and make them wait | Receive 30 seconds of oral then wait | 4 | text: "Receive 30 seconds of oral then wait" / partner: "Give oral for 30 seconds, stop, and make them wait" |
| 26 | Describe everything you're going to do without doing it yet | Listen to everything your partner will do | 3 | text: "Listen to everything your partner will do" / partner: "Describe everything you're going to do without doing it yet" |
| 27 | Give your partner oral but stop right before they finish | Receive oral that stops right before you finish | 4 | text: "Receive oral that stops right before you finish" / partner: "Give your partner oral but stop right before they finish" |
| 28 | Touch your partner through their clothes for 10 minutes | Be touched through your clothes for 10 minutes | 3 | text: "Be touched through your clothes for 10 minutes" / partner: "Touch your partner through their clothes for 10 minutes" |
| 29 | Send your partner a suggestive photo during dinner | Receive a suggestive photo during dinner | 3 | text: "Receive a suggestive photo during dinner" / partner: "Send your partner a suggestive photo during dinner" |
| 30 | Kiss your partner's neck and stop when they want more | Have your neck kissed and then teased | 3 | text: "Have your neck kissed and then teased" / partner: "Kiss your partner's neck and stop when they want more" |
| 31 | Make your partner ask permission before you give them more | Ask permission before receiving more | 4 | text: "Ask permission before receiving more" / partner: "Make your partner ask permission before you give them more" |
| 32 | Use your mouth close to your partner's skin without touching | Feel your partner's mouth hover without touching | 3 | text: "Feel your partner's mouth hover without touching" / partner: "Use your mouth close to your partner's skin without touching" |
| 33 | Trace your fingers over your partner's body feather-light | Have feather-light touches traced on your body | 3 | text: "Have feather-light touches traced on your body" / partner: "Trace your fingers over your partner's body feather-light" |
# The Kink Lab (34 missing)
## 🔞 Forbidden Fantasy Dates (9 missing)
| # | Current Text | Current Partner Text | Intensity | Inverse to Create |
|---|--------------|---------------------|-----------|-------------------|
| 1 | Use nipple clamps on your partner | Let your partner use nipple clamps on you | 4 | text: "Let your partner use nipple clamps on you" / partner: "Use nipple clamps on your partner" |
| 2 | Let your partner use a clitoris clamp on you | Use a clitoris clamp on your partner | 5 | text: "Use a clitoris clamp on your partner" / partner: "Let your partner use a clitoris clamp on you" |
| 3 | Finger your partner while wearing black latex gloves | Be fingered by your partner wearing black latex gloves | 3 | text: "Be fingered by your partner wearing black latex gloves" / partner: "Finger your partner while wearing black latex gloves" |
| 4 | Edge your partner and deny their orgasm | Let your partner edge you and deny your orgasm | 5 | text: "Let your partner edge you and deny your orgasm" / partner: "Edge your partner and deny their orgasm" |
| 5 | Make your partner cum through domination | Let your partner dominate you until you cum | 5 | text: "Let your partner dominate you until you cum" / partner: "Make your partner cum through domination" |
| 6 | Blindfold your partner and plug their ears to take control | Be blindfolded with ears plugged while your partner controls you | 5 | text: "Be blindfolded with ears plugged while your partner controls you" / partner: "Blindfold your partner and plug their ears to take control" |
| 7 | Talk dirty about breeding your partner | Listen to your partner talk dirty about breeding you | 5 | text: "Listen to your partner talk dirty about breeding you" / partner: "Talk dirty about breeding your partner" |
| 8 | Let your partner take total control | Take total control of your partner | 3 | text: "Take total control of your partner" / partner: "Let your partner take total control" |
| 9 | Spank your partner firmly | Receive a firm spanking from your partner | 4 | text: "Receive a firm spanking from your partner" / partner: "Spank your partner firmly" |
## ✨ Kink Discovery (25 missing)
| # | Current Text | Current Partner Text | Intensity | Inverse to Create |
|---|--------------|---------------------|-----------|-------------------|
| 1 | Gag your partner during sex | Let your partner gag you during sex | 5 | text: "Let your partner gag you during sex" / partner: "Gag your partner during sex" |
| 2 | Hold your partner down during sex | Be held down by your partner during sex | 4 | text: "Be held down by your partner during sex" / partner: "Hold your partner down during sex" |
| 3 | Put a collar on your partner | Wear a collar for your partner | 4 | text: "Wear a collar for your partner" / partner: "Put a collar on your partner" |
| 4 | Blindfold your partner during sex | Be blindfolded by your partner during sex | 3 | text: "Be blindfolded by your partner during sex" / partner: "Blindfold your partner during sex" |
| 5 | Pull your partner's hair during sex | Let your partner pull your hair during sex | 4 | text: "Let your partner pull your hair during sex" / partner: "Pull your partner's hair during sex" |
| 6 | Make your partner ask permission to finish | Ask your partner for permission to finish | 4 | text: "Ask your partner for permission to finish" / partner: "Make your partner ask permission to finish" |
| 7 | Place your hands on your partner's throat | Let your partner's hands rest on your throat | 3 | text: "Let your partner's hands rest on your throat" / partner: "Place your hands on your partner's throat" |
| 8 | Worship your partner's body with kisses | Have your body worshipped with kisses | 2 | text: "Have your body worshipped with kisses" / partner: "Worship your partner's body with kisses" |
| 9 | Make your partner beg for it | Let your partner make you beg for it | 4 | text: "Let your partner make you beg for it" / partner: "Make your partner beg for it" |
| 10 | Use playful, naughty names for your partner | Be called playful, naughty names by your partner | 3 | text: "Be called playful, naughty names by your partner" / partner: "Use playful, naughty names for your partner" |
| 11 | Swallow your partner's release | Have your partner swallow your release | 3 | text: "Have your partner swallow your release" / partner: "Swallow your partner's release" |
| 12 | Finish on your partner's face | Let your partner finish on your face | 4 | text: "Let your partner finish on your face" / partner: "Finish on your partner's face" |
| 13 | Pull your partner's hair during sex | Have your hair pulled during sex | 4 | text: "Have your hair pulled during sex" / partner: "Pull your partner's hair during sex" |
| 14 | Put a collar on your partner | Wear a collar for your partner | 5 | text: "Wear a collar for your partner" / partner: "Put a collar on your partner" |
| 15 | Gag your partner during sex | Be gagged during sex | 5 | text: "Be gagged during sex" / partner: "Gag your partner during sex" |
| 16 | Blindfold your partner during sex | Be blindfolded during sex | 3 | text: "Be blindfolded during sex" / partner: "Blindfold your partner during sex" |
| 17 | Make your partner ask permission before they can cum | Ask permission before you're allowed to cum | 4 | text: "Ask permission before you're allowed to cum" / partner: "Make your partner ask permission before they can cum" |
| 18 | Choke your partner lightly during sex | Be choked lightly during sex | 5 | text: "Be choked lightly during sex" / partner: "Choke your partner lightly during sex" |
| 19 | Hold your partner down and fuck them | Be held down and fucked | 5 | text: "Be held down and fucked" / partner: "Hold your partner down and fuck them" |
| 20 | Worship your partner's body completely | Have your body worshipped and kissed everywhere | 3 | text: "Have your body worshipped and kissed everywhere" / partner: "Worship your partner's body completely" |
| 21 | Make your partner beg for it | Be made to beg for it | 4 | text: "Be made to beg for it" / partner: "Make your partner beg for it" |
| 22 | Call your partner a slut or whore during sex | Be called a slut or whore during sex | 5 | text: "Be called a slut or whore during sex" / partner: "Call your partner a slut or whore during sex" |
| 23 | Have your partner swallow | Swallow your partner's cum | 4 | text: "Swallow your partner's cum" / partner: "Have your partner swallow" |
| 24 | Fuck your partner's face | Have your face fucked | 5 | text: "Have your face fucked" / partner: "Fuck your partner's face" |
| 25 | Finish on your partner's face | Have your partner finish on your face | 5 | text: "Have your partner finish on your face" / partner: "Finish on your partner's face" |
# Toy Time (128 missing)
## 🔞 Leveling Up Together (45 missing)
| # | Current Text | Current Partner Text | Intensity | Inverse to Create |
|---|--------------|---------------------|-----------|-------------------|
| 1 | Attach a vibrator to your partner's inner thigh while you tease them elsewhere | Have a vibrator strapped to your thigh while your partner explores your body | 3 | text: "Have a vibrator strapped to your thigh while your partner explores your body" / partner: "Attach a vibrator to your partner's inner thigh while you tease them elsewhere" |
| 2 | Use a vibrating butt plug on your partner while giving them a slow handjob | Wear a vibrating plug while receiving a teasing handjob | 4 | text: "Wear a vibrating plug while receiving a teasing handjob" / partner: "Use a vibrating butt plug on your partner while giving them a slow handjob" |
| 3 | Blindfold your partner and make them guess which toy you're using | Guess the toy while blindfolded as your partner teases you | 3 | text: "Guess the toy while blindfolded as your partner teases you" / partner: "Blindfold your partner and make them guess which toy you're using" |
| 4 | Record your partner's reactions while you use a powerful toy on them | Let your partner film your pleasure as they use a toy on you | 4 | text: "Let your partner film your pleasure as they use a toy on you" / partner: "Record your partner's reactions while you use a powerful toy on them" |
| 5 | Tie your partner's wrists to the headboard and tease them with a vibrator | Be tied to the headboard and teased with a vibrator | 4 | text: "Be tied to the headboard and teased with a vibrator" / partner: "Tie your partner's wrists to the headboard and tease them with a vibrator" |
| 6 | Control the speed of your partner's toy while they try not to make noise | Use a toy while trying to stay quiet as your partner controls it | 4 | text: "Use a toy while trying to stay quiet as your partner controls it" / partner: "Control the speed of your partner's toy while they try not to make noise" |
| 7 | Wear a strap-on and let your partner control a remote vibrator in the base | Use a strap-on with a vibrator that your partner controls | 4 | text: "Use a strap-on with a vibrator that your partner controls" / partner: "Wear a strap-on and let your partner control a remote vibrator in the base" |
| 8 | Have your partner hold a vibrator on themselves while you penetrate them | Hold a vibrator on yourself while your partner penetrates you | 4 | text: "Hold a vibrator on yourself while your partner penetrates you" / partner: "Have your partner hold a vibrator on themselves while you penetrate them" |
| 9 | Use a textured sleeve on your partner while they watch porn together with you | Watch porn together while your partner uses a textured sleeve on you | 4 | text: "Watch porn together while your partner uses a textured sleeve on you" / partner: "Use a textured sleeve on your partner while they watch porn together with you" |
| 10 | Alternate between your mouth and a vibrator on your partner without warning | Be teased with unpredictable switches between oral and vibration | 4 | text: "Be teased with unpredictable switches between oral and vibration" / partner: "Alternate between your mouth and a vibrator on your partner without warning" |
| 11 | Perform a striptease and make your partner cum with a toy | Watch your partner strip before they make you cum with a toy | 4 | text: "Watch your partner strip before they make you cum with a toy" / partner: "Perform a striptease and make your partner cum with a toy" |
| 12 | Use a vibrating prostate massager on your partner during a handjob | Receive a handjob while wearing a vibrating prostate massager | 4 | text: "Receive a handjob while wearing a vibrating prostate massager" / partner: "Use a vibrating prostate massager on your partner during a handjob" |
| 13 | Perform oral while using nipple suction toys on your partner | Receive oral while your partner uses nipple suction toys on you | 4 | text: "Receive oral while your partner uses nipple suction toys on you" / partner: "Perform oral while using nipple suction toys on your partner" |
| 14 | Use a dildo on your partner to the beat of the music | Be penetrated by a dildo to the beat of the music | 4 | text: "Be penetrated by a dildo to the beat of the music" / partner: "Use a dildo on your partner to the beat of the music" |
| 15 | Use a warming lubricant and a glass dildo on your partner | Feel the contrast of warming lube with a cool glass toy inside you | 4 | text: "Feel the contrast of warming lube with a cool glass toy inside you" / partner: "Use a warming lubricant and a glass dildo on your partner" |
| 16 | Warm massage oil and a vibrating wand on your partner's entire body | Receive a full-body massage with warm oil and vibrations | 3 | text: "Receive a full-body massage with warm oil and vibrations" / partner: "Warm massage oil and a vibrating wand on your partner's entire body" |
| 17 | Insert anal beads into your partner slowly during foreplay | Have anal beads inserted slowly while your partner builds your arousal | 4 | text: "Have anal beads inserted slowly while your partner builds your arousal" / partner: "Insert anal beads into your partner slowly during foreplay" |
| 18 | Use a prostate or G-spot vibrator on your partner during oral | Receive oral while your partner stimulates your G-spot or P-spot | 4 | text: "Receive oral while your partner stimulates your G-spot or P-spot" / partner: "Use a prostate or G-spot vibrator on your partner during oral" |
| 19 | Blindfold your partner and tease them with ice and a warming vibrator | Be blindfolded and teased with ice and a warming vibrator | 4 | text: "Be blindfolded and teased with ice and a warming vibrator" / partner: "Blindfold your partner and tease them with ice and a warming vibrator" |
| 20 | Edge your partner with a wand, bringing them close twice before letting them finish | Be edged with a wand vibrator, brought close twice before release | 4 | text: "Be edged with a wand vibrator, brought close twice before release" / partner: "Edge your partner with a wand, bringing them close twice before letting them finish" |
| 21 | Control a remote vibrator inside your partner while out at dinner | Wear a remote-controlled vibrator while your partner teases you at dinner | 4 | text: "Wear a remote-controlled vibrator while your partner teases you at dinner" / partner: "Control a remote vibrator inside your partner while out at dinner" |
| 22 | Wear a vibrating cock ring while your partner rides you | Ride your partner while they wear a vibrating cock ring | 4 | text: "Ride your partner while they wear a vibrating cock ring" / partner: "Wear a vibrating cock ring while your partner rides you" |
| 23 | Have your partner wear vibrating panties while running errands together | Wear vibrating panties in public while your partner controls the intensity | 4 | text: "Wear vibrating panties in public while your partner controls the intensity" / partner: "Have your partner wear vibrating panties while running errands together" |
| 24 | Let your partner control a toy inside you during a video call with friends | Wear a hidden vibrator during a video call while your partner controls it | 4 | text: "Wear a hidden vibrator during a video call while your partner controls it" / partner: "Let your partner control a toy inside you during a video call with friends" |
| 25 | Drip massage candle wax on your partner while using a vibrator on them | Feel warm wax drip on your skin while your partner vibrates you | 4 | text: "Feel warm wax drip on your skin while your partner vibrates you" / partner: "Drip massage candle wax on your partner while using a vibrator on them" |
| 26 | Let your partner choose the toy and the setting while you lie back | Take full control of which toy and intensity to use on your partner | 4 | text: "Take full control of which toy and intensity to use on your partner" / partner: "Let your partner choose the toy and the setting while you lie back" |
| 27 | Take complete control of your partner's pleasure for an entire session | Surrender all control and let your partner decide everything | 5 | text: "Surrender all control and let your partner decide everything" / partner: "Take complete control of your partner's pleasure for an entire session" |
| 28 | Use a toy on your partner while lightly pulling their hair | Feel your hair pulled while your partner uses a toy on you | 5 | text: "Feel your hair pulled while your partner uses a toy on you" / partner: "Use a toy on your partner while lightly pulling their hair" |
| 29 | Blindfold your partner and make them hold completely still while you tease them | Stay perfectly still while blindfolded as your partner teases you with toys | 5 | text: "Stay perfectly still while blindfolded as your partner teases you with toys" / partner: "Blindfold your partner and make them hold completely still while you tease them" |
| 30 | Edge your partner until they're shaking, then finally let them finish | Be edged until you're trembling before your partner lets you come | 5 | text: "Be edged until you're trembling before your partner lets you come" / partner: "Edge your partner until they're shaking, then finally let them finish" |
| 31 | Use nipple clamps on your partner while penetrating them with a toy | Wear nipple clamps while your partner penetrates you with a toy | 5 | text: "Wear nipple clamps while your partner penetrates you with a toy" / partner: "Use nipple clamps on your partner while penetrating them with a toy" |
| 32 | Put a collar on your partner while pleasuring them with a vibrator | Wear a collar while your partner uses a vibrator on you | 5 | text: "Wear a collar while your partner uses a vibrator on you" / partner: "Put a collar on your partner while pleasuring them with a vibrator" |
| 33 | Restrain your partner's hands behind their back while using toys on them | Have your hands bound behind you while your partner pleasures you with toys | 5 | text: "Have your hands bound behind you while your partner pleasures you with toys" / partner: "Restrain your partner's hands behind their back while using toys on them" |
| 34 | Make your partner ask permission before they're allowed to orgasm | Ask your partner for permission before you're allowed to come | 5 | text: "Ask your partner for permission before you're allowed to come" / partner: "Make your partner ask permission before they're allowed to orgasm" |
| 35 | Blindfold and cuff your partner while using a wand on them | Be blindfolded and cuffed while your partner uses a wand on you | 5 | text: "Be blindfolded and cuffed while your partner uses a wand on you" / partner: "Blindfold and cuff your partner while using a wand on them" |
| 36 | Tie your partner's ankles to the bedposts and tease them with toys | Have your ankles tied while your partner teases you with toys | 5 | text: "Have your ankles tied while your partner teases you with toys" / partner: "Tie your partner's ankles to the bedposts and tease them with toys" |
| 37 | Edge your partner three times before finally letting them finish | Be edged three times before your partner allows you release | 4 | text: "Be edged three times before your partner allows you release" / partner: "Edge your partner three times before finally letting them finish" |
| 38 | Use a toy on your partner from behind while they grip the sheets | Grip the sheets while your partner uses a toy on you from behind | 4 | text: "Grip the sheets while your partner uses a toy on you from behind" / partner: "Use a toy on your partner from behind while they grip the sheets" |
| 39 | Use a thrusting toy on your partner while kissing their neck | Feel a thrusting toy while your partner kisses your neck | 4 | text: "Feel a thrusting toy while your partner kisses your neck" / partner: "Use a thrusting toy on your partner while kissing their neck" |
| 40 | Make your partner describe what they're feeling while you use a toy on them | Describe every sensation out loud while your partner toys you | 4 | text: "Describe every sensation out loud while your partner toys you" / partner: "Make your partner describe what they're feeling while you use a toy on them" |
| 41 | Use a suction toy on your partner while penetrating them | Feel suction stimulation while your partner penetrates you | 4 | text: "Feel suction stimulation while your partner penetrates you" / partner: "Use a suction toy on your partner while penetrating them" |
| 42 | Tease your partner with a wand through their underwear until they beg | Be teased through your underwear until you're begging for more | 4 | text: "Be teased through your underwear until you're begging for more" / partner: "Tease your partner with a wand through their underwear until they beg" |
| 43 | Use two different vibrators on your partner at the same time | Feel two vibrators stimulating different areas simultaneously | 4 | text: "Feel two vibrators stimulating different areas simultaneously" / partner: "Use two different vibrators on your partner at the same time" |
| 44 | Make your partner hold a vibrator on themselves while you watch | Hold a vibrator on yourself while your partner watches | 4 | text: "Hold a vibrator on yourself while your partner watches" / partner: "Make your partner hold a vibrator on themselves while you watch" |
| 45 | Use a vibrating plug on your partner while they go down on you | Wear a vibrating plug while giving your partner oral | 4 | text: "Wear a vibrating plug while giving your partner oral" / partner: "Use a vibrating plug on your partner while they go down on you" |
## 🔞 Starting Out (36 missing)
| # | Current Text | Current Partner Text | Intensity | Inverse to Create |
|---|--------------|---------------------|-----------|-------------------|
| 1 | Give your partner a backrub using a small vibrator | Receive a backrub with a small vibrator | 3 | text: "Receive a backrub with a small vibrator" / partner: "Give your partner a backrub using a small vibrator" |
| 2 | Tease your partner with a bullet vibrator over their clothes | Be teased with a bullet vibrator through your clothes | 3 | text: "Be teased with a bullet vibrator through your clothes" / partner: "Tease your partner with a bullet vibrator over their clothes" |
| 3 | Use a feather tickler on your blindfolded partner | Be blindfolded and teased with a feather tickler | 3 | text: "Be blindfolded and teased with a feather tickler" / partner: "Use a feather tickler on your blindfolded partner" |
| 4 | Have sex using a small dildo on your partner | Be penetrated with a small dildo during sex | 4 | text: "Be penetrated with a small dildo during sex" / partner: "Have sex using a small dildo on your partner" |
| 5 | Tease your partner with feathers and a heating massager | Be teased with feathers and a heating massager | 3 | text: "Be teased with feathers and a heating massager" / partner: "Tease your partner with feathers and a heating massager" |
| 6 | Press a wand vibrator against your partner during missionary | Have a wand vibrator pressed against you during missionary | 4 | text: "Have a wand vibrator pressed against you during missionary" / partner: "Press a wand vibrator against your partner during missionary" |
| 7 | Insert a small butt plug into your partner before you have sex | Have a small butt plug inserted before you have sex | 4 | text: "Have a small butt plug inserted before you have sex" / partner: "Insert a small butt plug into your partner before you have sex" |
| 8 | Wear a vibrating egg while you have sex | Have sex while your partner wears a vibrating egg | 4 | text: "Have sex while your partner wears a vibrating egg" / partner: "Wear a vibrating egg while you have sex" |
| 9 | Use a clitoral suction toy and a dildo on your partner simultaneously | Be penetrated while your partner uses a suction toy on your clit | 4 | text: "Be penetrated while your partner uses a suction toy on your clit" / partner: "Use a clitoral suction toy and a dildo on your partner simultaneously" |
| 10 | Use a textured sleeve on your partner's cock | Have a textured sleeve used on your cock | 4 | text: "Have a textured sleeve used on your cock" / partner: "Use a textured sleeve on your partner's cock" |
| 11 | Perform oral while using a small vibrator on your partner | Receive oral while your partner uses a small vibrator on you | 3 | text: "Receive oral while your partner uses a small vibrator on you" / partner: "Perform oral while using a small vibrator on your partner" |
| 12 | Blindfold your partner and penetrate them with a textured dildo | Be blindfolded and penetrated with a textured dildo | 4 | text: "Be blindfolded and penetrated with a textured dildo" / partner: "Blindfold your partner and penetrate them with a textured dildo" |
| 13 | Blindfold your partner and use a vibrating sleeve on their G-spot or P-spot | Be blindfolded while your partner stimulates your G-spot or P-spot with a vibrating sleeve | 4 | text: "Be blindfolded while your partner stimulates your G-spot or P-spot with a vibrating sleeve" / partner: "Blindfold your partner and use a vibrating sleeve on their G-spot or P-spot" |
| 14 | Blindfold your partner and tease their nipples with weighted clamps | Be blindfolded and have weighted clamps placed on your nipples | 5 | text: "Be blindfolded and have weighted clamps placed on your nipples" / partner: "Blindfold your partner and tease their nipples with weighted clamps" |
| 15 | Tie your partner's hands with a scarf and kiss their body | Have your hands tied with a scarf while your partner kisses you | 3 | text: "Have your hands tied with a scarf while your partner kisses you" / partner: "Tie your partner's hands with a scarf and kiss their body" |
| 16 | Record your partner using a toy on themselves | Let your partner record you using a toy on yourself | 5 | text: "Let your partner record you using a toy on yourself" / partner: "Record your partner using a toy on themselves" |
| 17 | Tease your partner with a toy under the covers in bed | Be teased with a toy under the blankets | 4 | text: "Be teased with a toy under the blankets" / partner: "Tease your partner with a toy under the covers in bed" |
| 18 | Shop for a new toy together online | Browse and pick out a new toy with your partner | 3 | text: "Browse and pick out a new toy with your partner" / partner: "Shop for a new toy together online" |
| 19 | Unbox a new toy together and explore how it feels | Discover a new toy together with your partner | 3 | text: "Discover a new toy together with your partner" / partner: "Unbox a new toy together and explore how it feels" |
| 20 | Use a warming massager on your partner's lower back | Feel warmth and vibration on your lower back | 3 | text: "Feel warmth and vibration on your lower back" / partner: "Use a warming massager on your partner's lower back" |
| 21 | Use a massage wand on your partner's back and shoulders | Receive a relaxing massage with a vibrating wand | 3 | text: "Receive a relaxing massage with a vibrating wand" / partner: "Use a massage wand on your partner's back and shoulders" |
| 22 | Trace a small vibrator slowly along your partner's arms and legs | Feel a vibrator traced gently across your limbs | 3 | text: "Feel a vibrator traced gently across your limbs" / partner: "Trace a small vibrator slowly along your partner's arms and legs" |
| 23 | Tease your partner's inner thighs with a bullet vibrator | Feel a bullet vibrator teasing your inner thighs | 3 | text: "Feel a bullet vibrator teasing your inner thighs" / partner: "Tease your partner's inner thighs with a bullet vibrator" |
| 24 | Use a vibrator on your partner while giving them a hand massage | Receive a hand massage while feeling vibrations elsewhere | 3 | text: "Receive a hand massage while feeling vibrations elsewhere" / partner: "Use a vibrator on your partner while giving them a hand massage" |
| 25 | Give your partner a foot massage using a small vibrator | Receive a foot massage with vibrating sensations | 3 | text: "Receive a foot massage with vibrating sensations" / partner: "Give your partner a foot massage using a small vibrator" |
| 26 | Hold a vibrator against your partner while cuddling on the couch | Feel vibrations while cuddling with your partner | 3 | text: "Feel vibrations while cuddling with your partner" / partner: "Hold a vibrator against your partner while cuddling on the couch" |
| 27 | Use a vibrating ring during foreplay | Feel the vibrating ring while making out with your partner | 3 | text: "Feel the vibrating ring while making out with your partner" / partner: "Use a vibrating ring during foreplay" |
| 28 | Tease your partner with a vibrator while they're still fully clothed | Feel vibrations through your clothes as your partner teases you | 3 | text: "Feel vibrations through your clothes as your partner teases you" / partner: "Tease your partner with a vibrator while they're still fully clothed" |
| 29 | Use a bullet vibrator on your partner while kissing them deeply | Be kissed while your partner uses a bullet vibrator on you | 3 | text: "Be kissed while your partner uses a bullet vibrator on you" / partner: "Use a bullet vibrator on your partner while kissing them deeply" |
| 30 | Let your partner choose which toy to use on you | Pick a toy from the collection to use on your partner | 3 | text: "Pick a toy from the collection to use on your partner" / partner: "Let your partner choose which toy to use on you" |
| 31 | Use a soft vibrator on your partner's neck and collarbone | Feel gentle vibrations on your neck and collarbone | 3 | text: "Feel gentle vibrations on your neck and collarbone" / partner: "Use a soft vibrator on your partner's neck and collarbone" |
| 32 | Use a small dildo on your partner while whispering what you want to do to them | Be penetrated with a toy while your partner whispers in your ear | 4 | text: "Be penetrated with a toy while your partner whispers in your ear" / partner: "Use a small dildo on your partner while whispering what you want to do to them" |
| 33 | Let your partner guide your hand while you hold a toy on them | Guide your partner's hand to show them where the toy feels best | 3 | text: "Guide your partner's hand to show them where the toy feels best" / partner: "Let your partner guide your hand while you hold a toy on them" |
| 34 | Let your partner use a toy on you while you touch yourself | Use a toy on your partner while they pleasure themselves | 4 | text: "Use a toy on your partner while they pleasure themselves" / partner: "Let your partner use a toy on you while you touch yourself" |
| 35 | Use a vibrator on your partner while they watch themselves in a mirror | Watch yourself in a mirror while your partner uses a toy on you | 4 | text: "Watch yourself in a mirror while your partner uses a toy on you" / partner: "Use a vibrator on your partner while they watch themselves in a mirror" |
| 36 | Use cooling lube with a small toy on your partner | Feel the cool sensation as your partner uses a toy with cooling lube | 4 | text: "Feel the cool sensation as your partner uses a toy with cooling lube" / partner: "Use cooling lube with a small toy on your partner" |
## ✨ The Deep End (47 missing)
| # | Current Text | Current Partner Text | Intensity | Inverse to Create |
|---|--------------|---------------------|-----------|-------------------|
| 1 | Lock your partner in a collar and leash, using toys on them while they kneel | Wear a collar and kneel while your partner uses toys on you | 5 | text: "Wear a collar and kneel while your partner uses toys on you" / partner: "Lock your partner in a collar and leash, using toys on them while they kneel" |
| 2 | Restrain your partner in a sex swing and use multiple toys on them | Be suspended in a swing while your partner pleasures you with toys | 5 | text: "Be suspended in a swing while your partner pleasures you with toys" / partner: "Restrain your partner in a sex swing and use multiple toys on them" |
| 3 | Use a toy and a vibrating plug on your partner | Be filled by a toy and a vibrating plug | 5 | text: "Be filled by a toy and a vibrating plug" / partner: "Use a toy and a vibrating plug on your partner" |
| 4 | Control a remote toy for your partner all day | Wear a remote toy for your partner to control all day | 4 | text: "Wear a remote toy for your partner to control all day" / partner: "Control a remote toy for your partner all day" |
| 5 | Suspend your partner's wrists above them and tease their body with a pinwheel and wand | Have your arms suspended while your partner torments you with sensation toys | 5 | text: "Have your arms suspended while your partner torments you with sensation toys" / partner: "Suspend your partner's wrists above them and tease their body with a pinwheel and wand" |
| 6 | Use two vibrating toys on your partner at once | Be filled by two vibrating toys at once | 5 | text: "Be filled by two vibrating toys at once" / partner: "Use two vibrating toys on your partner at once" |
| 7 | Restrain your partner's legs and use a suction toy | Be restrained while your partner uses a suction toy | 4 | text: "Be restrained while your partner uses a suction toy" / partner: "Restrain your partner's legs and use a suction toy" |
| 8 | Use a powerful wand on your partner during penetration | Feel a powerful wand while your partner penetrates you | 4 | text: "Feel a powerful wand while your partner penetrates you" / partner: "Use a powerful wand on your partner during penetration" |
| 9 | Slowly expand an inflatable toy inside your partner | Let your partner slowly expand an inflatable toy inside you | 5 | text: "Let your partner slowly expand an inflatable toy inside you" / partner: "Slowly expand an inflatable toy inside your partner" |
| 10 | Use sensation pads and a vibrator on your partner | Feel sensation pads and a vibrator on your thighs | 5 | text: "Feel sensation pads and a vibrator on your thighs" / partner: "Use sensation pads and a vibrator on your partner" |
| 11 | Use a rhythmic thrusting machine on your partner | Be pleasured by a thrusting machine while your partner controls it | 5 | text: "Be pleasured by a thrusting machine while your partner controls it" / partner: "Use a rhythmic thrusting machine on your partner" |
| 12 | Edge your partner five times before allowing them to peak | Let your partner edge you five times before you reach a peak | 4 | text: "Let your partner edge you five times before you reach a peak" / partner: "Edge your partner five times before allowing them to peak" |
| 13 | Use a TENS unit on your partner while simultaneously penetrating them with a toy | Feel electrical stimulation while your partner uses a toy inside you | 5 | text: "Feel electrical stimulation while your partner uses a toy inside you" / partner: "Use a TENS unit on your partner while simultaneously penetrating them with a toy" |
| 14 | Blindfold your partner and use a sensation wheel | Be blindfolded while your partner uses a sensation wheel | 5 | text: "Be blindfolded while your partner uses a sensation wheel" / partner: "Blindfold your partner and use a sensation wheel" |
| 15 | Make your partner ride a mounted dildo while you control a vibrator on them | Ride a dildo while your partner controls additional stimulation | 4 | text: "Ride a dildo while your partner controls additional stimulation" / partner: "Make your partner ride a mounted dildo while you control a vibrator on them" |
| 16 | Use a thrusting dildo on your partner while they're restrained and blindfolded | Be bound and blindfolded while a thrusting toy penetrates you | 5 | text: "Be bound and blindfolded while a thrusting toy penetrates you" / partner: "Use a thrusting dildo on your partner while they're restrained and blindfolded" |
| 17 | Bring your partner to a peak using only internal toys | Reach a peak using only internal stimulation | 4 | text: "Reach a peak using only internal stimulation" / partner: "Bring your partner to a peak using only internal toys" |
| 18 | Use a thrusting machine for anal play while using a wand on your partner | Experience a thrusting machine and wand stimulation simultaneously | 5 | text: "Experience a thrusting machine and wand stimulation simultaneously" / partner: "Use a thrusting machine for anal play while using a wand on your partner" |
| 19 | Tie your partner in a hogtie and edge them repeatedly | Be bound in a hogtie while your partner edges you | 5 | text: "Be bound in a hogtie while your partner edges you" / partner: "Tie your partner in a hogtie and edge them repeatedly" |
| 20 | Use a sensation wand on your partner's sensitive spots | Feel the tingle of a sensation wand on your body | 5 | text: "Feel the tingle of a sensation wand on your body" / partner: "Use a sensation wand on your partner's sensitive spots" |
| 21 | Attach weighted nipple clamps to your partner and use a powerful wand on them | Wear weighted clamps while your partner overwhelms you with a wand | 5 | text: "Wear weighted clamps while your partner overwhelms you with a wand" / partner: "Attach weighted nipple clamps to your partner and use a powerful wand on them" |
| 22 | Use an internal massager to tease your partner | Let your partner tease you with an internal massager | 4 | text: "Let your partner tease you with an internal massager" / partner: "Use an internal massager to tease your partner" |
| 23 | Restrain your partner and use a vibrator on them | Be restrained while your partner uses a vibrator on you | 5 | text: "Be restrained while your partner uses a vibrator on you" / partner: "Restrain your partner and use a vibrator on them" |
| 24 | Tease your partner while they wear a locked toy | Be teased by your partner while wearing a locked toy | 5 | text: "Be teased by your partner while wearing a locked toy" / partner: "Tease your partner while they wear a locked toy" |
| 25 | Restrain your partner with a spreader bar and tease them with a wand | Be restrained with a spreader bar while your partner teases you | 5 | text: "Be restrained with a spreader bar while your partner teases you" / partner: "Restrain your partner with a spreader bar and tease them with a wand" |
| 26 | Use a large plug and a powerful wand on your partner until they have multiple orgasms | Be filled and vibrated until you orgasm again and again | 5 | text: "Be filled and vibrated until you orgasm again and again" / partner: "Use a large plug and a powerful wand on your partner until they have multiple orgasms" |
| 27 | Make your partner count their edges out loud while you control the toy | Count each edge out loud while your partner decides when you've had enough | 5 | text: "Count each edge out loud while your partner decides when you've had enough" / partner: "Make your partner count their edges out loud while you control the toy" |
| 28 | Put your partner in predicament bondage where struggling increases toy intensity | Be bound so that every movement makes the vibrations stronger | 5 | text: "Be bound so that every movement makes the vibrations stronger" / partner: "Put your partner in predicament bondage where struggling increases toy intensity" |
| 29 | Restrain your partner in a strict position and methodically overstimulate every erogenous zone with toys | Be bound strictly while your partner systematically overwhelms every sensitive part of your body | 5 | text: "Be bound strictly while your partner systematically overwhelms every sensitive part of your body" / partner: "Restrain your partner in a strict position and methodically overstimulate every erogenous zone with toys" |
| 30 | Use a hollow plug on your partner so you can penetrate them while they're already filled | Wear a hollow plug while your partner penetrates you through it | 5 | text: "Wear a hollow plug while your partner penetrates you through it" / partner: "Use a hollow plug on your partner so you can penetrate them while they're already filled" |
| 31 | Edge your partner for an hour using various toys before allowing release | Be edged with different toys for an extended session before finally coming | 5 | text: "Be edged with different toys for an extended session before finally coming" / partner: "Edge your partner for an hour using various toys before allowing release" |
| 32 | Use a large toy on your partner with plenty of lube | Be filled by a large toy with plenty of lube | 4 | text: "Be filled by a large toy with plenty of lube" / partner: "Use a large toy on your partner with plenty of lube" |
| 33 | Use a rotating and thrusting toy on your partner while restraining their hips | Have your hips pinned while a toy rotates and thrusts inside you | 5 | text: "Have your hips pinned while a toy rotates and thrusts inside you" / partner: "Use a rotating and thrusting toy on your partner while restraining their hips" |
| 34 | Bring your partner to a peak while they are gagged | Let your partner bring you to a peak while gagged | 5 | text: "Let your partner bring you to a peak while gagged" / partner: "Bring your partner to a peak while they are gagged" |
| 35 | Use clover clamps on your partner that tighten when pulled while you penetrate them with a toy | Feel clamps tighten with each thrust as your partner toys you | 5 | text: "Feel clamps tighten with each thrust as your partner toys you" / partner: "Use clover clamps on your partner that tighten when pulled while you penetrate them with a toy" |
| 36 | Connect nipple clamps with a chain to your partner's collar while using a toy on them | Wear connected clamps and collar while your partner pleasures you with a toy | 5 | text: "Wear connected clamps and collar while your partner pleasures you with a toy" / partner: "Connect nipple clamps with a chain to your partner's collar while using a toy on them" |
| 37 | Use a large textured dildo on your partner while they wear a ball gag | Be gagged and stretched by a large textured toy | 5 | text: "Be gagged and stretched by a large textured toy" / partner: "Use a large textured dildo on your partner while they wear a ball gag" |
| 38 | Use a thrusting toy on your partner while spanking them | Be penetrated with a thrusting toy while your partner spanks you | 5 | text: "Be penetrated with a thrusting toy while your partner spanks you" / partner: "Use a thrusting toy on your partner while spanking them" |
| 39 | Use a pump on your partner to increase sensitivity before using other toys | Have your sensitivity heightened by a pump before your partner uses toys on you | 5 | text: "Have your sensitivity heightened by a pump before your partner uses toys on you" / partner: "Use a pump on your partner to increase sensitivity before using other toys" |
| 40 | Tie your partner face down and use a thrusting toy on them from behind | Be bound face down while a toy thrusts into you | 5 | text: "Be bound face down while a toy thrusts into you" / partner: "Tie your partner face down and use a thrusting toy on them from behind" |
| 41 | Bind your partner completely immobile and use toys on them until you decide they're done | Be completely restrained while your partner controls all stimulation | 5 | text: "Be completely restrained while your partner controls all stimulation" / partner: "Bind your partner completely immobile and use toys on them until you decide they're done" |
| 42 | Strap vibrators to multiple parts of your partner's body and turn them all on at once | Have vibrators attached to several erogenous zones buzzing simultaneously | 5 | text: "Have vibrators attached to several erogenous zones buzzing simultaneously" / partner: "Strap vibrators to multiple parts of your partner's body and turn them all on at once" |
| 43 | Strap a wand to your partner and leave it on medium while you do other things to them | Have a wand strapped to you as constant stimulation while your partner plays | 5 | text: "Have a wand strapped to you as constant stimulation while your partner plays" / partner: "Strap a wand to your partner and leave it on medium while you do other things to them" |
| 44 | Use electro-stimulation on your partner while they edge themselves with a toy | Shock yourself lightly while edging with a toy at your partner's command | 5 | text: "Shock yourself lightly while edging with a toy at your partner's command" / partner: "Use electro-stimulation on your partner while they edge themselves with a toy" |
| 45 | Make your partner edge themselves while you watch and give instructions | Edge yourself under your partner's commands while they watch | 5 | text: "Edge yourself under your partner's commands while they watch" / partner: "Make your partner edge themselves while you watch and give instructions" |
| 46 | Blindfold your partner and make them guess how many toys are inside them | Guess how many toys your partner has put inside you while blindfolded | 4 | text: "Guess how many toys your partner has put inside you while blindfolded" / partner: "Blindfold your partner and make them guess how many toys are inside them" |
| 47 | Tease your partner right to the edge before stopping | Be teased to the edge before your partner stops | 5 | text: "Be teased to the edge before your partner stops" / partner: "Tease your partner right to the edge before stopping" |
+69 -1070
View File
File diff suppressed because it is too large. Load diff
+7
View File
@@ -21,5 +21,12 @@
"packageManager": "npm@10.2.0",
"engines": {
"node": ">=20"
},
"overrides": {
"rollup": "4.54.0"
},
"optionalDependencies": {
"@rollup/rollup-linux-x64-musl": "4.54.0",
"@rollup/rollup-linux-arm64-musl": "4.54.0"
}
}
+4
View File
@@ -40,7 +40,11 @@ export interface Question {
intensity: 1 | 2 | 3 | 4 | 5;
allowed_couple_genders?: string[] | null;
target_user_genders?: string[] | null;
required_props?: string[] | null;
/** References the primary question that this is an inverse of (e.g., give vs receive). NULL means this is a primary/standalone question. */
inverse_of?: string | null;
created_at: string;
deleted_at?: string | null;
}
// User's answer type
File diff suppressed because it is too large. Load diff
+5
View File
@@ -0,0 +1,5 @@
packages:
- "apps/admin"
- "apps/mobile"
- "apps/supabase"
- "packages/*"