From 4e40b92b16f33d20d5bda4ba46d914db0441181d Mon Sep 17 00:00:00 2001 From: Jamie Brunton Date: Sat, 10 Jan 2026 19:24:06 +0000 Subject: [PATCH] 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. --- .claude/agents/pack-generator.md | 90 + .claude/commands/generate-pack.md | 148 + PACK_GENERATION_AGENT.md | 303 ++ apps/admin/src/App.tsx | 18 + .../src/components/ai/GenerationForm.tsx | 4 +- .../components/ai/hooks/useAiGeneration.ts | 4 +- .../src/components/content/PackFormDialog.tsx | 37 +- .../content/ReviewQuestionsDialog.tsx | 483 ++- .../src/components/layout/Breadcrumbs.tsx | 2 + apps/admin/src/components/layout/Sidebar.tsx | 14 + apps/admin/src/components/ui/checkbox.tsx | 5 +- apps/admin/src/hooks/useAiConfig.ts | 7 + apps/admin/src/hooks/useCrudOperations.ts | 16 +- apps/admin/src/lib/ai/analyzers/deletions.ts | 101 + apps/admin/src/lib/ai/analyzers/media.ts | 75 +- apps/admin/src/lib/ai/analyzers/props.ts | 89 + apps/admin/src/lib/ai/analyzers/targets.ts | 96 +- apps/admin/src/lib/ai/analyzers/text.ts | 110 +- apps/admin/src/lib/ai/client.ts | 25 + apps/admin/src/lib/ai/config.ts | 182 +- .../admin/src/lib/ai/generators/categories.ts | 89 +- .../src/lib/ai/generators/cherry-pick.ts | 94 +- apps/admin/src/lib/ai/generators/council.ts | 3 +- apps/admin/src/lib/ai/generators/packs.ts | 137 +- apps/admin/src/lib/ai/generators/questions.ts | 117 +- apps/admin/src/lib/ai/index.ts | 4 + apps/admin/src/lib/ai/review.ts | 176 +- apps/admin/src/lib/ai/tools/polish.ts | 34 +- apps/admin/src/lib/ai/tools/topics.ts | 57 +- apps/admin/src/lib/ai/types.ts | 43 +- apps/admin/src/pages/AiSettingsPage.tsx | 274 +- .../src/pages/content/CategoriesPage.tsx | 127 +- .../admin/src/pages/content/DarePacksPage.tsx | 9 +- apps/admin/src/pages/content/PacksPage.tsx | 25 +- .../pages/content/QuestionAnalyticsPage.tsx | 821 +++++ .../admin/src/pages/content/QuestionsPage.tsx | 70 +- apps/admin/src/pages/content/TagsPage.tsx | 459 +++ apps/admin/src/services/categories.ts | 7 +- apps/admin/src/services/packs.ts | 3 +- apps/admin/src/services/questions.ts | 5 +- apps/mcp/.env.example | 4 + apps/mcp/Dockerfile | 14 + apps/mcp/package-lock.json | 2772 +++++++++++++++++ apps/mcp/package.json | 25 + apps/mcp/src/index.ts | 28 + apps/mcp/src/lib/auth.ts | 25 + apps/mcp/src/lib/supabase.ts | 22 + apps/mcp/src/server.ts | 37 + apps/mcp/src/tools/admin.ts | 150 + apps/mcp/src/tools/analytics.ts | 116 + apps/mcp/src/tools/codes.ts | 153 + apps/mcp/src/tools/config.ts | 123 + apps/mcp/src/tools/content.ts | 371 +++ apps/mcp/src/tools/feedback.ts | 124 + apps/mcp/src/tools/moderation.ts | 147 + apps/mcp/src/tools/users.ts | 217 ++ apps/mcp/src/utils/audit.ts | 81 + apps/mcp/tsconfig.json | 12 + apps/mobile/app/(app)/packs/questions.tsx | 46 +- apps/mobile/app/pack/[id].tsx | 2 +- .../src/components/ui/IntensitySlider.tsx | 10 +- apps/mobile/src/store/packsStore.ts | 61 +- apps/mobile/src/store/responsesStore.ts | 6 +- apps/mobile/src/types/index.ts | 18 +- apps/mobile/src/types/supabase.ts | 401 ++- ...20260110000000_add_category_visibility.sql | 255 ++ ...2_add_temperature_control_to_ai_config.sql | 7 + ...000000_add_required_props_to_questions.sql | 5 + docs/schema.md | 1 + packages/shared/src/types/index.ts | 1 + packages/shared/src/types/supabase.ts | 1922 ++++++++++-- 71 files changed, 10511 insertions(+), 1008 deletions(-) create mode 100644 .claude/agents/pack-generator.md create mode 100644 .claude/commands/generate-pack.md create mode 100644 PACK_GENERATION_AGENT.md create mode 100644 apps/admin/src/lib/ai/analyzers/deletions.ts create mode 100644 apps/admin/src/lib/ai/analyzers/props.ts create mode 100644 apps/admin/src/pages/content/QuestionAnalyticsPage.tsx create mode 100644 apps/admin/src/pages/content/TagsPage.tsx create mode 100644 apps/mcp/.env.example create mode 100644 apps/mcp/Dockerfile create mode 100644 apps/mcp/package-lock.json create mode 100644 apps/mcp/package.json create mode 100644 apps/mcp/src/index.ts create mode 100644 apps/mcp/src/lib/auth.ts create mode 100644 apps/mcp/src/lib/supabase.ts create mode 100644 apps/mcp/src/server.ts create mode 100644 apps/mcp/src/tools/admin.ts create mode 100644 apps/mcp/src/tools/analytics.ts create mode 100644 apps/mcp/src/tools/codes.ts create mode 100644 apps/mcp/src/tools/config.ts create mode 100644 apps/mcp/src/tools/content.ts create mode 100644 apps/mcp/src/tools/feedback.ts create mode 100644 apps/mcp/src/tools/moderation.ts create mode 100644 apps/mcp/src/tools/users.ts create mode 100644 apps/mcp/src/utils/audit.ts create mode 100644 apps/mcp/tsconfig.json create mode 100644 apps/supabase/migrations/20260110000000_add_category_visibility.sql create mode 100644 apps/supabase/migrations/20260110000002_add_temperature_control_to_ai_config.sql create mode 100644 apps/supabase/migrations/20260111000000_add_required_props_to_questions.sql diff --git a/.claude/agents/pack-generator.md b/.claude/agents/pack-generator.md new file mode 100644 index 0000000..c555f33 --- /dev/null +++ b/.claude/agents/pack-generator.md @@ -0,0 +1,90 @@ +# 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 + +### 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 +``` + +**Use partner_text when roles differ:** +``` +text: "Give your partner oral" +partner_text: "Receive oral from your partner" +``` + +**CRITICAL - Always create the inverse:** +``` +text: "Receive oral from your partner" +partner_text: "Give your partner oral" +``` + +### 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 +5. **Review**: Check all rules, remove duplicates, verify intensity +6. **Format**: Output as SQL or table format + +## 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 diff --git a/.claude/commands/generate-pack.md b/.claude/commands/generate-pack.md new file mode 100644 index 0000000..136cdd5 --- /dev/null +++ b/.claude/commands/generate-pack.md @@ -0,0 +1,148 @@ +--- +allowed-tools: Read, mcp__sauci-prod__execute_sql, mcp__sauci-prod__list_tables +argument-hint: --category= --intensity= [--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: !`mcp__sauci-prod__execute_sql` with query: SELECT name, id FROM categories ORDER BY sort_order +- Existing packs in target category: Will query once category is identified + +## 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. 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: + +#### Format Rules +- Questions are PROPOSALS, not interview questions +- Use "your partner" - never gendered pronouns +- No time-specific language (tonight, now, today) +- Keep concise: 5-15 words ideal +- Modern language for 25-40 year olds +- Avoid cheesy/cliche phrasing + +#### Partner Text Rules +- **NULL** for symmetric activities (both do the same thing) +- **Filled** for asymmetric activities (different roles) + +#### Critical: Inverse Questions +For EVERY asymmetric question, create its inverse: + +``` +Original: + text: "Spank your partner" + partner_text: "Be spanked by your partner" + +Inverse (MUST CREATE): + text: "Be spanked by your partner" + partner_text: "Spank your partner" +``` + +This ensures both partners are asked about both roles. + +#### Intensity Guidelines +- **1**: Non-sexual bonding (cooking, gaming, quality time) +- **2**: Romantic, flirty (kisses, cuddles, sweet messages) +- **3**: Light sexual (oral, mutual masturbation, basic toys) +- **4**: Explicit sexual (sex, light bondage, anal play) +- **5**: Intense/BDSM (impact play, power exchange, edge play) + +### Step 4: Review & Validate + +Before outputting, verify: +- [ ] All asymmetric questions have inverses +- [ ] No gendered pronouns +- [ ] No time-specific language +- [ ] Questions are proposals, not questions +- [ ] Intensity levels are accurate +- [ ] No cheesy language +- [ ] Varied sentence structures +- [ ] Considers same-sex couples +- [ ] 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 (use returned pack_id) +INSERT INTO questions (pack_id, text, partner_text, intensity) VALUES +('[pack_id]', 'Question text', 'Partner text or NULL', intensity), +-- ... all questions +``` + +Also provide a summary table: + +| Metric | Value | +|--------|-------| +| Total questions | X | +| Intensity 1 | X | +| Intensity 2 | X | +| Intensity 3 | X | +| Intensity 4 | X | +| Intensity 5 | X | +| Symmetric (null partner_text) | X | +| Asymmetric (with inverses) | X pairs | + +## 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 +- DO vary sentence structure and openers +- DON'T use cheesy or cliche language +- DON'T assume heterosexual couples +- DON'T skip the inverse question requirement +- DON'T use interview-style questions ("Would you like to...") diff --git a/PACK_GENERATION_AGENT.md b/PACK_GENERATION_AGENT.md new file mode 100644 index 0000000..da64d7e --- /dev/null +++ b/PACK_GENERATION_AGENT.md @@ -0,0 +1,303 @@ +# 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 + +### 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 for Asymmetric Activities + +For any asymmetric question, you MUST create TWO questions to cover both perspectives: + +**Question 1:** +``` +text: "Spank your partner" +partner_text: "Be spanked by your partner" +``` + +**Question 2 (inverse):** +``` +text: "Be spanked by your partner" +partner_text: "Spank your partner" +``` + +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 + +--- + +## 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 + +### 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: + +```sql +-- Pack: [Pack Name] +-- Category: [Category Name] +-- Description: [Pack description] +-- Intensity range: [X-Y] + +INSERT INTO questions (pack_id, text, partner_text, intensity) VALUES +('[pack_id]', 'Question text here', 'Partner text or NULL', intensity), +('[pack_id]', 'Question text here', NULL, intensity), +-- ... etc +``` + +Or as a table: + +| Text | Partner Text | Intensity | +|------|--------------|-----------| +| Question here | Partner perspective or null | 4 | + +--- + +## Examples of Good Questions + +### Non-Sexual (Intensity 1) +``` +text: "Cook the same recipe together over video call" +partner_text: NULL +intensity: 1 +``` + +### Romantic (Intensity 2) +``` +text: "Send a tipsy voice note telling them you miss them" +partner_text: NULL +intensity: 2 +``` + +### Playful (Intensity 3) +``` +text: "Blindfold your partner and tease them with different sensations" +partner_text: "Be blindfolded while your partner teases you" +intensity: 3 +``` +AND its inverse: +``` +text: "Be blindfolded while your partner teases you" +partner_text: "Blindfold your partner and tease them with different sensations" +intensity: 3 +``` + +### Steamy (Intensity 4) +``` +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 +``` +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 +``` + +### 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 +``` +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 +``` + +--- + +## Checklist Before Submitting + +- [ ] All asymmetric questions have their inverse created +- [ ] No gendered pronouns (him/her/he/she) +- [ ] No time-specific language (tonight, now, today) +- [ ] Questions are proposals, not interview questions +- [ ] Intensity levels are accurate +- [ ] No cheesy or cliche language +- [ ] Mix of sentence structures +- [ ] Appropriate for 25-40 year old couples +- [ ] Consider same-sex couples +- [ ] Pack fits its position in the progression diff --git a/apps/admin/src/App.tsx b/apps/admin/src/App.tsx index b0c663c..64ca1c0 100755 --- a/apps/admin/src/App.tsx +++ b/apps/admin/src/App.tsx @@ -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() { } /> + + + + } + /> + + + + } + /> )} - {/* Wildness Level Selector (questions only) */} + {/* Intimacy Level Selector (questions only) */} {type === 'questions' && (
- +
{TONE_LEVELS.map((t) => (
+ {/* Category */} +
+ + +
+ {/* Premium Toggle */}
diff --git a/apps/admin/src/components/content/ReviewQuestionsDialog.tsx b/apps/admin/src/components/content/ReviewQuestionsDialog.tsx index 2f27744..217b0a6 100644 --- a/apps/admin/src/components/content/ReviewQuestionsDialog.tsx +++ b/apps/admin/src/components/content/ReviewQuestionsDialog.tsx @@ -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([]); + const [existingProps, setExistingProps] = useState([]); const [selectedTextIds, setSelectedTextIds] = useState>(new Set()); const [selectedTargetIds, setSelectedTargetIds] = useState>(new Set()); + const [selectedPropsIds, setSelectedPropsIds] = useState>(new Set()); + const [selectedIntensityIds, setSelectedIntensityIds] = useState>(new Set()); + const [selectedDeleteIds, setSelectedDeleteIds] = useState>(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 => { + 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(); + 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 }[] = []; + 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 = {}; 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[] = 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 ( - + Review Questions - AI-powered analysis to improve text phrasing and fix audience targeting. + AI-powered analysis to improve phrasing, identify props, fix targeting, and suggest deletions. @@ -258,7 +435,7 @@ export function ReviewQuestionsDialog({ open, onOpenChange, questions, isExplici

Ready to analyze {questions.length} questions

- The AI will analyze all questions and suggest improvements for: + The AI will analyze all questions and suggest improvements or removals for:

@@ -270,6 +447,18 @@ export function ReviewQuestionsDialog({ open, onOpenChange, questions, isExplici Audience Targeting
+
+ + Required Props +
+
+ + Intensity Level +
+
+ + Deletion Suggestions +
) : (
- {suggestions.length === 0 ? ( + {!hasSuggestions ? (
All questions look great! No improvements needed. @@ -302,6 +491,18 @@ export function ReviewQuestionsDialog({ open, onOpenChange, questions, isExplici {targetSuggestionCount} target fixes + + + {propsSuggestionCount} props fixes + + + + {intensitySuggestionCount} intensity fixes + + + + {deleteSuggestionCount} delete suggestions +
-
+
- Question - + Question +
Text Fix @@ -332,7 +533,7 @@ export function ReviewQuestionsDialog({ open, onOpenChange, questions, isExplici
- +
Target Fix @@ -346,32 +547,78 @@ export function ReviewQuestionsDialog({ open, onOpenChange, questions, isExplici
+ +
+ + Props + +
+
+ +
+ + Intensity + {intensitySuggestionCount > 0 && ( + + )} +
+
+ +
+ + Delete + {deleteSuggestionCount > 0 && ( + + )} +
+
{suggestions.map((suggestion) => ( {/* Question Column */} - -
{suggestion.question.text}
+ +
{suggestion.question.text}
{suggestion.question.partner_text && ( -
+
Partner: {suggestion.question.partner_text}
)} {/* Text Fix Column */} - + {suggestion.textSuggestion ? (
toggleTextSelection(suggestion.questionId)} > -
+
toggleTextSelection(suggestion.questionId)} @@ -382,11 +629,11 @@ export function ReviewQuestionsDialog({ open, onOpenChange, questions, isExplici {suggestion.textSuggestion.suggested_text}
{suggestion.textSuggestion.suggested_partner_text && ( -
+
Partner: {suggestion.textSuggestion.suggested_partner_text}
)} -
+
{suggestion.textSuggestion.reason}
@@ -401,16 +648,16 @@ export function ReviewQuestionsDialog({ open, onOpenChange, questions, isExplici {/* Target Fix Column */} - + {suggestion.targetSuggestion ? (
toggleTargetSelection(suggestion.questionId)} > -
+
toggleTargetSelection(suggestion.questionId)} @@ -418,26 +665,28 @@ export function ReviewQuestionsDialog({ open, onOpenChange, questions, isExplici />
{/* Couple targets */} -
- +
+ Couples: + {formatTargets(suggestion.question.allowed_couple_genders)} - + {formatTargets(suggestion.targetSuggestion.suggested_targets)}
{/* Initiator */} -
- +
+ Initiator: + {formatInitiator(suggestion.question.target_user_genders)} - + {formatInitiator(suggestion.targetSuggestion.suggested_initiator)}
-
+
{suggestion.targetSuggestion.reason}
@@ -450,6 +699,130 @@ export function ReviewQuestionsDialog({ open, onOpenChange, questions, isExplici
)} + + {/* Props Column */} + + {suggestion.propsSuggestion ? ( +
togglePropsSelection(suggestion.questionId)} + > +
+ togglePropsSelection(suggestion.questionId)} + className="mt-0.5" + /> +
+
+ Current: + + {formatProps(suggestion.question.required_props)} + + + + {formatProps(suggestion.propsSuggestion.suggested_required_props)} + +
+
+ {suggestion.propsSuggestion.reason} +
+
+
+
+ ) : ( +
+ + No changes needed +
+ )} +
+ + {/* Intensity Column */} + +
+ + Current: {suggestion.question.intensity} + +
+ {suggestion.textSuggestion?.suggested_intensity != null ? ( +
toggleIntensitySelection(suggestion.questionId)} + > +
+ toggleIntensitySelection(suggestion.questionId)} + className="mt-0.5" + /> +
+
+ + + {suggestion.textSuggestion.suggested_intensity} + +
+ {suggestion.textSuggestion.intensity_reason && ( +
+ {suggestion.textSuggestion.intensity_reason} +
+ )} +
+
+
+ ) : ( +
+ + Correct +
+ )} +
+ + {/* Delete Column */} + + {suggestion.deleteSuggestion ? ( +
toggleDeleteSelection(suggestion.questionId)} + > +
+ toggleDeleteSelection(suggestion.questionId)} + className="mt-0.5" + /> +
+
+ {formatDeleteCategory(suggestion.deleteSuggestion.category)} recommended +
+ {suggestion.deleteSuggestion.duplicate_of_id && ( +
+ Duplicate of: {getQuestionTextById(suggestion.deleteSuggestion.duplicate_of_id) || 'Another question'} +
+ )} +
+ {suggestion.deleteSuggestion.reason} +
+
+
+
+ ) : ( +
+ + Keep +
+ )} +
))} @@ -478,7 +851,7 @@ export function ReviewQuestionsDialog({ open, onOpenChange, questions, isExplici Applying... ) : ( - `Apply ${totalSelectedCount} Changes` + `Apply ${totalSelectedCount} Actions` )} diff --git a/apps/admin/src/components/layout/Breadcrumbs.tsx b/apps/admin/src/components/layout/Breadcrumbs.tsx index 45547c0..d39d161 100644 --- a/apps/admin/src/components/layout/Breadcrumbs.tsx +++ b/apps/admin/src/components/layout/Breadcrumbs.tsx @@ -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 diff --git a/apps/admin/src/components/layout/Sidebar.tsx b/apps/admin/src/components/layout/Sidebar.tsx index b993317..0ef29bf 100644 --- a/apps/admin/src/components/layout/Sidebar.tsx +++ b/apps/admin/src/components/layout/Sidebar.tsx @@ -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: , requiredPermission: PERMISSION_KEYS.MANAGE_PACKS, }, + { + label: 'Question Analytics', + href: '/question-analytics', + icon: , + requiredPermission: PERMISSION_KEYS.MANAGE_QUESTIONS, + }, + { + label: 'Tags', + href: '/tags', + icon: , + requiredPermission: PERMISSION_KEYS.MANAGE_PACKS, + }, ]; const usersNav: NavItem[] = [ diff --git a/apps/admin/src/components/ui/checkbox.tsx b/apps/admin/src/components/ui/checkbox.tsx index ec0d7a7..3be6d6b 100644 --- a/apps/admin/src/components/ui/checkbox.tsx +++ b/apps/admin/src/components/ui/checkbox.tsx @@ -3,15 +3,16 @@ import { cn } from "@/lib/utils"; import { Check } from "lucide-react"; export interface CheckboxProps - extends Omit, 'type' | 'onChange'> { + extends Omit, 'onChange'> { checked?: boolean; onCheckedChange?: (checked: boolean) => void; } -const Checkbox = React.forwardRef( +const Checkbox = React.forwardRef( ({ className, checked, onCheckedChange, ...props }, ref) => { return (
-
- - handleChange('classifier_model', value)} - placeholder="openai/gpt-4o" - /> -

- Model used for classification (must support vision if image moderation is needed) -

+
+
+ + handleChange('classifier_model', value)} + placeholder="openai/gpt-4o" + /> +

+ Model used for classification (must support vision if image moderation is needed) +

+
+
+ + handleChange('classifier_temperature', e.target.value === '' ? null : Number(e.target.value))} + placeholder="1.0" + /> +
@@ -624,16 +651,31 @@ export function AiSettingsPage() { -
- - handleChange('default_model', value)} - placeholder="openai/gpt-4o-mini" - /> -

- Fallback model when no specific model is configured -

+
+
+ + handleChange('default_model', value)} + placeholder="openai/gpt-4o-mini" + /> +

+ Fallback model when no specific model is configured +

+
+
+ + handleChange('default_temperature', e.target.value === '' ? null : Number(e.target.value))} + placeholder="0.7" + /> +
@@ -663,16 +705,31 @@ export function AiSettingsPage() { {!formData.council_enabled ? ( /* Single generator mode */ -
- - handleChange('model_generate', value)} - placeholder="Uses default model" - /> -

- For questions, category ideas, and pack ideas -

+
+
+ + handleChange('model_generate', value)} + placeholder="Uses default model" + /> +

+ For questions, category ideas, and pack ideas +

+
+
+ + handleChange('temperature_generate', e.target.value === '' ? null : Number(e.target.value))} + placeholder="0.9" + /> +
) : ( /* Council mode - multiple generators + reviewer */ @@ -698,10 +755,21 @@ export function AiSettingsPage() {
updateGeneratorModel(index, value)} + onChange={(value) => updateGenerator(index, { model: value })} placeholder={index === 0 ? "anthropic/claude-3.5-sonnet" : "Select model..."} className="flex-1" /> + updateGenerator(index, { temperature: e.target.value === '' ? undefined : Number(e.target.value) })} + placeholder="0.9" + className="w-[80px]" + aria-label={`Generator ${index + 1} temperature`} + />
-
- -
-
-
- {category.name} - - {category.description || 'No description'} - - - -
- - {category.pack_count} pack{category.pack_count !== 1 ? 's' : ''} - - - - -
+ + + +
))} diff --git a/apps/admin/src/pages/content/DarePacksPage.tsx b/apps/admin/src/pages/content/DarePacksPage.tsx index 77904be..816be78 100644 --- a/apps/admin/src/pages/content/DarePacksPage.tsx +++ b/apps/admin/src/pages/content/DarePacksPage.tsx @@ -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'); diff --git a/apps/admin/src/pages/content/PacksPage.tsx b/apps/admin/src/pages/content/PacksPage.tsx index b484831..2594b7e 100644 --- a/apps/admin/src/pages/content/PacksPage.tsx +++ b/apps/admin/src/pages/content/PacksPage.tsx @@ -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(null); + const [categories, setCategories] = useState([]); const [packs, setPacks] = useState([]); 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} diff --git a/apps/admin/src/pages/content/QuestionAnalyticsPage.tsx b/apps/admin/src/pages/content/QuestionAnalyticsPage.tsx new file mode 100644 index 0000000..804f40a --- /dev/null +++ b/apps/admin/src/pages/content/QuestionAnalyticsPage.tsx @@ -0,0 +1,821 @@ +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 } from 'lucide-react'; + +interface QuestionRow { + id: string; + pack_id: string; + intensity: number | null; + partner_text: string | null; +} + +interface PackRow { + id: string; + name: string; + is_premium: boolean; + is_explicit: 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]; +}; + +export function QuestionAnalyticsPage() { + const [loading, setLoading] = useState(true); + const [stats, setStats] = useState({ + totalQuestions: 0, + premiumQuestions: 0, + freeQuestions: 0, + taggedQuestions: 0, + }); + const [premiumBreakdown, setPremiumBreakdown] = useState([]); + const [intensityBreakdown, setIntensityBreakdown] = useState>({ + all: [], + premium: [], + free: [], + }); + const [topicBreakdown, setTopicBreakdown] = useState>({ + all: [], + premium: [], + free: [], + }); + const [categoryBreakdown, setCategoryBreakdown] = useState>({ + all: [], + premium: [], + free: [], + }); + const [packBreakdown, setPackBreakdown] = useState>({ + all: [], + premium: [], + free: [], + }); + const [intensitySegment, setIntensitySegment] = useState('all'); + const [tagSegment, setTagSegment] = useState('all'); + const [categorySegment, setCategorySegment] = useState('all'); + const [packSegment, setPackSegment] = useState('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') + .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 { + const [packsResult, topicsResult, packTopicsResult, questions] = await Promise.all([ + supabase + .from('question_packs') + .select('id, name, is_premium, is_explicit, category_id, category:categories(name)'), + 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(); + packs.forEach((pack) => { + packById.set(pack.id, pack); + }); + + const seedTopicCounts = () => new Map( + topics.map((topic) => [topic.id, { id: topic.id, name: topic.name, count: 0 }]) + ); + + const topicCountsBySegment: Record> = { + all: seedTopicCounts(), + premium: seedTopicCounts(), + free: seedTopicCounts(), + }; + + const packTopicsMap = new Map(); + 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> = { + all: { ...emptyIntensityCounts }, + premium: { ...emptyIntensityCounts }, + free: { ...emptyIntensityCounts }, + }; + const untaggedCounts: Record = { all: 0, premium: 0, free: 0 }; + const categoryCountsBySegment: Record> = { + all: new Map(), + premium: new Map(), + free: new Map(), + }; + const packCountsBySegment: Record> = { + all: new Map(), + premium: new Map(), + free: new Map(), + }; + + let premiumCount = 0; + let freeCount = 0; + let taggedCount = 0; + + questions.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) => { + 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 = questions.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(); + }, []); + + 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 = { + all: 'All', + premium: 'Premium', + free: 'Free', + }; + + const totalBySegment: Record = { + all: stats.totalQuestions, + premium: stats.premiumQuestions, + free: stats.freeQuestions, + }; + + const segmentShareByKey: Record = { + 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]; + + return ( +
+
+

+ + Question Analytics +

+

+ Deep dive into premium coverage, tags, categories, and intensity distribution +

+
+ +
+ + + Total Questions + + + + {loading ? ( + + ) : ( +
+ {stats.totalQuestions.toLocaleString()} +
+ )} +
+
+ + + + Premium Questions + + + + {loading ? ( + + ) : ( + <> +
+ {stats.premiumQuestions.toLocaleString()} +
+

+ {premiumPercent}% of all questions +

+ + )} +
+
+ + + + Free Questions + + + + {loading ? ( + + ) : ( + <> +
+ {stats.freeQuestions.toLocaleString()} +
+

+ {freePercent}% of all questions +

+ + )} +
+
+ + + + Tagged Questions + + + + {loading ? ( + + ) : ( + <> +
+ {stats.taggedQuestions.toLocaleString()} +
+

+ {taggedPercent}% of all questions +

+ + )} +
+
+
+ +
+ + + Premium vs Free + + Question count split by pack premium status + + + + {loading ? ( + + ) : stats.totalQuestions === 0 ? ( +
+ No question data available yet +
+ ) : ( +
+ + + + `${name}: ${((percent ?? 0) * 100).toFixed(0)}%` + } + > + {premiumBreakdown.map((entry, index) => ( + + ))} + + + + +
+ )} +
+
+ + + +
+ Intensity Distribution + + How questions are spread across intensity levels + +
+
+
+ {segmentOptions.map((option) => ( + + ))} +
+

+ {segmentLabelByKey[intensitySegment]}: {totalBySegment[intensitySegment].toLocaleString()} questions ({segmentShareByKey[intensitySegment]}%) +

+
+
+ + {loading ? ( + + ) : totalBySegment[intensitySegment] === 0 ? ( +
+ No questions in this segment yet +
+ ) : ( +
+ + + + `${value}`} + /> + + + {intensityData.map((entry, index) => ( + + ))} + + + +
+ )} +
+
+
+ + + +
+ Tag Breakdown + + Tags are derived from pack topics; questions in multi-tag packs count toward each tag + +
+
+
+ {segmentOptions.map((option) => ( + + ))} +
+

+ {segmentLabelByKey[tagSegment]}: {totalBySegment[tagSegment].toLocaleString()} questions ({segmentShareByKey[tagSegment]}%) +

+
+
+ + {loading ? ( +
+ {Array.from({ length: 6 }).map((_, index) => ( + + ))} +
+ ) : totalBySegment[tagSegment] === 0 ? ( +
+ No questions in this segment yet +
+ ) : tagData.length === 0 ? ( +
+ No tag data available yet +
+ ) : ( +
+ {tagData.map((item) => { + const percentage = formatPercent(item.count, totalBySegment[tagSegment]); + return ( +
+
+
+

{item.name}

+ + {item.count.toLocaleString()} + +
+
+
+
+
+
+ {percentage}% +
+
+ ); + })} +
+ )} + + + +
+ + +
+ Category Breakdown + + Distribution of questions across pack categories + +
+
+
+ {segmentOptions.map((option) => ( + + ))} +
+

+ {segmentLabelByKey[categorySegment]}: {totalBySegment[categorySegment].toLocaleString()} questions ({segmentShareByKey[categorySegment]}%) +

+
+
+ + {loading ? ( +
+ {Array.from({ length: 5 }).map((_, index) => ( + + ))} +
+ ) : totalBySegment[categorySegment] === 0 ? ( +
+ No questions in this segment yet +
+ ) : categoryData.length === 0 ? ( +
+ No category data available yet +
+ ) : ( +
+ {categoryData.map((item) => { + const percentage = formatPercent(item.count, totalBySegment[categorySegment]); + return ( +
+
+

{item.name}

+

{percentage}% of questions

+
+
+

{item.count.toLocaleString()}

+

questions

+
+
+ ); + })} +
+ )} +
+
+ + + +
+ Top Packs + + Packs with the most questions right now + +
+
+
+ {segmentOptions.map((option) => ( + + ))} +
+

+ {segmentLabelByKey[packSegment]}: {totalBySegment[packSegment].toLocaleString()} questions ({segmentShareByKey[packSegment]}%) +

+
+
+ + {loading ? ( +
+ {Array.from({ length: 5 }).map((_, index) => ( + + ))} +
+ ) : totalBySegment[packSegment] === 0 ? ( +
+ No questions in this segment yet +
+ ) : packData.length === 0 ? ( +
+ No pack data available yet +
+ ) : ( +
+ {packData.slice(0, 8).map((pack) => ( +
+
+

{pack.name}

+
+ {pack.category} + {pack.isPremium && ( + + Premium + + )} +
+
+
+

{pack.count.toLocaleString()}

+

questions

+
+
+ ))} +
+ )} +
+
+
+
+ ); +} diff --git a/apps/admin/src/pages/content/QuestionsPage.tsx b/apps/admin/src/pages/content/QuestionsPage.tsx index 256cdb9..2489d19 100644 --- a/apps/admin/src/pages/content/QuestionsPage.tsx +++ b/apps/admin/src/pages/content/QuestionsPage.tsx @@ -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'; @@ -51,6 +52,7 @@ interface Question { intensity: number; allowed_couple_genders: string[] | null; target_user_genders: string[] | null; + required_props?: string[] | null; created_at: string | null; } @@ -67,6 +69,8 @@ export function QuestionsPage() { const [dialogOpen, setDialogOpen] = useState(false); const [aiDialogOpen, setAiDialogOpen] = useState(false); const [reviewOpen, setReviewOpen] = useState(false); + const [reviewQuestions, setReviewQuestions] = useState([]); + const [reviewLoading, setReviewLoading] = useState(false); const [editingQuestion, setEditingQuestion] = useState(null); const [saving, setSaving] = useState(false); const [selectedIds, setSelectedIds] = useState>(new Set()); @@ -82,12 +86,14 @@ export function QuestionsPage() { intensity: number; allowed_couple_genders: string[]; target_user_genders: string[]; + required_props: string; }>({ text: '', partner_text: '', intensity: 1, allowed_couple_genders: [], target_user_genders: [], + required_props: '', }); const fetchData = useCallback(async () => { @@ -161,6 +167,8 @@ export function QuestionsPage() { useEffect(() => { setPage(1); setSelectedIds(new Set()); + setReviewQuestions([]); + setReviewOpen(false); }, [packId]); useEffect(() => { @@ -169,7 +177,14 @@ export function QuestionsPage() { const openCreateDialog = () => { 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: '', + }); setDialogOpen(true); }; @@ -181,6 +196,7 @@ 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(', ') ?? '', }); setDialogOpen(true); }; @@ -191,6 +207,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 +222,7 @@ 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, }); if (error) throw error; @@ -212,6 +235,7 @@ 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, }); if (error) throw error; @@ -272,6 +296,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); @@ -348,8 +394,12 @@ export function QuestionsPage() {

-
+
+ + setFormData(d => ({ ...d, required_props: e.target.value }))} + placeholder="e.g., vibrator, restraints" + /> +

+ Comma-separated list of props/accessories needed for this question. +

+
@@ -580,7 +642,7 @@ export function QuestionsPage() { diff --git a/apps/admin/src/pages/content/TagsPage.tsx b/apps/admin/src/pages/content/TagsPage.tsx new file mode 100644 index 0000000..7a99ded --- /dev/null +++ b/apps/admin/src/pages/content/TagsPage.tsx @@ -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([]); + const [untaggedPacks, setUntaggedPacks] = useState([]); + 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(); + const packIdsWithTopics = new Set(); + + 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 = {}; + + 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 ( +
+
+ + +
+
+ + +
+
+ + +
+
+ ); + } + + return ( +
+
+
+

+ + Tags +

+

+ Active tags and packs that still need topic coverage +

+
+
+ + {taggingProgress?.packName && ( +

+ Working on {taggingProgress.packName} +

+ )} + {eligiblePackCount === 0 && untaggedPacks.length > 0 && ( +

+ Add questions to enable auto-tagging. +

+ )} +
+
+ +
+ + + Active Tags + + + +
{activeTags.length.toLocaleString()}
+

Tags assigned to at least one pack

+
+
+ + + Untagged Packs + + + +
{untaggedPacks.length.toLocaleString()}
+

Packs missing any tag coverage

+
+
+
+ +
+ + + Active Tags + Tag usage across question packs + + + {activeTags.length === 0 ? ( +
+ + No active tags yet +
+ ) : ( +
+ {activeTags.map((tag) => ( +
+

{tag.name}

+ + {tag.packCount} pack{tag.packCount !== 1 ? 's' : ''} + +
+ ))} +
+ )} +
+
+ + + +
+ Untagged Packs + These packs still need topics +
+ + {untaggedPacks.length} total + +
+ + {untaggedPacks.length === 0 ? ( +
+ + All packs are tagged +
+ ) : ( +
+ {untaggedPacks.map((pack) => ( +
+
+
+

{pack.name}

+ {pack.is_public ? ( + + Published + + ) : ( + Draft + )} + {pack.questionCount === 0 && ( + + No questions + + )} +
+

{pack.categoryName}

+
+
+

{pack.questionCount}

+

questions

+
+
+ ))} +
+ )} +
+
+
+
+ ); +} diff --git a/apps/admin/src/services/categories.ts b/apps/admin/src/services/categories.ts index 58164f0..f3dad23 100644 --- a/apps/admin/src/services/categories.ts +++ b/apps/admin/src/services/categories.ts @@ -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; diff --git a/apps/admin/src/services/packs.ts b/apps/admin/src/services/packs.ts index 060550f..4bf7006 100644 --- a/apps/admin/src/services/packs.ts +++ b/apps/admin/src/services/packs.ts @@ -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; } /** diff --git a/apps/admin/src/services/questions.ts b/apps/admin/src/services/questions.ts index f0b353f..e4ea366 100644 --- a/apps/admin/src/services/questions.ts +++ b/apps/admin/src/services/questions.ts @@ -84,7 +84,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; } /** @@ -138,7 +139,7 @@ export async function bulkCreateQuestions( }); if (error) throw error; - if (data) results.push(data as Question); + if (data && data.length > 0) results.push(data[0] as Question); } return results; diff --git a/apps/mcp/.env.example b/apps/mcp/.env.example new file mode 100644 index 0000000..5a2b5e0 --- /dev/null +++ b/apps/mcp/.env.example @@ -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 diff --git a/apps/mcp/Dockerfile b/apps/mcp/Dockerfile new file mode 100644 index 0000000..8e2da44 --- /dev/null +++ b/apps/mcp/Dockerfile @@ -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.js"] diff --git a/apps/mcp/package-lock.json b/apps/mcp/package-lock.json new file mode 100644 index 0000000..43f5fe9 --- /dev/null +++ b/apps/mcp/package-lock.json @@ -0,0 +1,2772 @@ +{ + "name": "sauci-mcp-server", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "sauci-mcp-server", + "version": "0.1.0", + "dependencies": { + "@hono/mcp": "^0.1.1", + "@modelcontextprotocol/sdk": "^1.0.1", + "@supabase/supabase-js": "^2.45.0", + "dotenv": "^16.4.5", + "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" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.2.tgz", + "integrity": "sha512-GZMB+a0mOMZs4MpDbj8RJp4cw+w1WV5NYD6xzgvzUJ5Ek2jerwfO2eADyI6ExDSUED+1X8aMbegahsJi+8mgpw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.2.tgz", + "integrity": "sha512-DVNI8jlPa7Ujbr1yjU2PfUSRtAUZPG9I1RwW4F4xFB1Imiu2on0ADiI/c3td+KmDtVKNbi+nffGDQMfcIMkwIA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.2.tgz", + "integrity": "sha512-pvz8ZZ7ot/RBphf8fv60ljmaoydPU12VuXHImtAs0XhLLw+EXBi2BLe3OYSBslR4rryHvweW5gmkKFwTiFy6KA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.2.tgz", + "integrity": "sha512-z8Ank4Byh4TJJOh4wpz8g2vDy75zFL0TlZlkUkEwYXuPSgX8yzep596n6mT7905kA9uHZsf/o2OJZubl2l3M7A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.2.tgz", + "integrity": "sha512-davCD2Zc80nzDVRwXTcQP/28fiJbcOwvdolL0sOiOsbwBa72kegmVU0Wrh1MYrbuCL98Omp5dVhQFWRKR2ZAlg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.2.tgz", + "integrity": "sha512-ZxtijOmlQCBWGwbVmwOF/UCzuGIbUkqB1faQRf5akQmxRJ1ujusWsb3CVfk/9iZKr2L5SMU5wPBi1UWbvL+VQA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.2.tgz", + "integrity": "sha512-lS/9CN+rgqQ9czogxlMcBMGd+l8Q3Nj1MFQwBZJyoEKI50XGxwuzznYdwcav6lpOGv5BqaZXqvBSiB/kJ5op+g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.2.tgz", + "integrity": "sha512-tAfqtNYb4YgPnJlEFu4c212HYjQWSO/w/h/lQaBK7RbwGIkBOuNKQI9tqWzx7Wtp7bTPaGC6MJvWI608P3wXYA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.2.tgz", + "integrity": "sha512-vWfq4GaIMP9AIe4yj1ZUW18RDhx6EPQKjwe7n8BbIecFtCQG4CfHGaHuh7fdfq+y3LIA2vGS/o9ZBGVxIDi9hw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.2.tgz", + "integrity": "sha512-hYxN8pr66NsCCiRFkHUAsxylNOcAQaxSSkHMMjcpx0si13t1LHFphxJZUiGwojB1a/Hd5OiPIqDdXONia6bhTw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.2.tgz", + "integrity": "sha512-MJt5BRRSScPDwG2hLelYhAAKh9imjHK5+NE/tvnRLbIqUWa+0E9N4WNMjmp/kXXPHZGqPLxggwVhz7QP8CTR8w==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.2.tgz", + "integrity": "sha512-lugyF1atnAT463aO6KPshVCJK5NgRnU4yb3FUumyVz+cGvZbontBgzeGFO1nF+dPueHD367a2ZXe1NtUkAjOtg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.2.tgz", + "integrity": "sha512-nlP2I6ArEBewvJ2gjrrkESEZkB5mIoaTswuqNFRv/WYd+ATtUpe9Y09RnJvgvdag7he0OWgEZWhviS1OTOKixw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.2.tgz", + "integrity": "sha512-C92gnpey7tUQONqg1n6dKVbx3vphKtTHJaNG2Ok9lGwbZil6DrfyecMsp9CrmXGQJmZ7iiVXvvZH6Ml5hL6XdQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.2.tgz", + "integrity": "sha512-B5BOmojNtUyN8AXlK0QJyvjEZkWwy/FKvakkTDCziX95AowLZKR6aCDhG7LeF7uMCXEJqwa8Bejz5LTPYm8AvA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.2.tgz", + "integrity": "sha512-p4bm9+wsPwup5Z8f4EpfN63qNagQ47Ua2znaqGH6bqLlmJ4bx97Y9JdqxgGZ6Y8xVTixUnEkoKSHcpRlDnNr5w==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.2.tgz", + "integrity": "sha512-uwp2Tip5aPmH+NRUwTcfLb+W32WXjpFejTIOWZFw/v7/KnpCDKG66u4DLcurQpiYTiYwQ9B7KOeMJvLCu/OvbA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.2.tgz", + "integrity": "sha512-Kj6DiBlwXrPsCRDeRvGAUb/LNrBASrfqAIok+xB0LxK8CHqxZ037viF13ugfsIpePH93mX7xfJp97cyDuTZ3cw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.2.tgz", + "integrity": "sha512-HwGDZ0VLVBY3Y+Nw0JexZy9o/nUAWq9MlV7cahpaXKW6TOzfVno3y3/M8Ga8u8Yr7GldLOov27xiCnqRZf0tCA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.2.tgz", + "integrity": "sha512-DNIHH2BPQ5551A7oSHD0CKbwIA/Ox7+78/AWkbS5QoRzaqlev2uFayfSxq68EkonB+IKjiuxBFoV8ESJy8bOHA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.2.tgz", + "integrity": "sha512-/it7w9Nb7+0KFIzjalNJVR5bOzA9Vay+yIPLVHfIQYG/j+j9VTH84aNB8ExGKPU4AzfaEvN9/V4HV+F+vo8OEg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.2.tgz", + "integrity": "sha512-LRBbCmiU51IXfeXk59csuX/aSaToeG7w48nMwA6049Y4J4+VbWALAuXcs+qcD04rHDuSCSRKdmY63sruDS5qag==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.2.tgz", + "integrity": "sha512-kMtx1yqJHTmqaqHPAzKCAkDaKsffmXkPHThSfRwZGyuqyIeBvf08KSsYXl+abf5HDAPMJIPnbBfXvP2ZC2TfHg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.2.tgz", + "integrity": "sha512-Yaf78O/B3Kkh+nKABUF++bvJv5Ijoy9AN1ww904rOXZFLWVc5OLOfL56W+C8F9xn5JQZa3UX6m+IktJnIb1Jjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.2.tgz", + "integrity": "sha512-Iuws0kxo4yusk7sw70Xa2E2imZU5HoixzxfGCdxwBdhiDgt9vX9VUCBhqcwY7/uh//78A1hMkkROMJq9l27oLQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.2.tgz", + "integrity": "sha512-sRdU18mcKf7F+YgheI/zGf5alZatMUTKj/jNS6l744f9u3WFu4v7twcUI9vu4mknF4Y9aDlblIie0IM+5xxaqQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@hono/mcp": { + "version": "0.1.5", + "resolved": "https://registry.npmjs.org/@hono/mcp/-/mcp-0.1.5.tgz", + "integrity": "sha512-q6Yurx9VUwVEpqnwVXtzIYaq4kgQgWWq9lYLM7NFS2W0sg1RzL+RdKh6jO4/dGyvBLKrahPd2v+NC6rr0XWBvQ==", + "license": "MIT", + "peerDependencies": { + "@modelcontextprotocol/sdk": "^1.12.0", + "hono": ">=4.0.0" + } + }, + "node_modules/@hono/node-server": { + "version": "1.19.8", + "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.8.tgz", + "integrity": "sha512-0/g2lIOPzX8f3vzW1ggQgvG5mjtFBDBHFAzI5SFAi2DzSqS9luJwqg9T6O/gKYLi+inS7eNxBeIFkkghIPvrMA==", + "license": "MIT", + "engines": { + "node": ">=18.14.1" + }, + "peerDependencies": { + "hono": "^4" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@modelcontextprotocol/sdk": { + "version": "1.25.2", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.25.2.tgz", + "integrity": "sha512-LZFeo4F9M5qOhC/Uc1aQSrBHxMrvxett+9KLHt7OhcExtoiRN9DKgbZffMP/nxjutWDQpfMDfP3nkHI4X9ijww==", + "license": "MIT", + "peer": true, + "dependencies": { + "@hono/node-server": "^1.19.7", + "ajv": "^8.17.1", + "ajv-formats": "^3.0.1", + "content-type": "^1.0.5", + "cors": "^2.8.5", + "cross-spawn": "^7.0.5", + "eventsource": "^3.0.2", + "eventsource-parser": "^3.0.0", + "express": "^5.0.1", + "express-rate-limit": "^7.5.0", + "jose": "^6.1.1", + "json-schema-typed": "^8.0.2", + "pkce-challenge": "^5.0.0", + "raw-body": "^3.0.0", + "zod": "^3.25 || ^4.0", + "zod-to-json-schema": "^3.25.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@cfworker/json-schema": "^4.1.1", + "zod": "^3.25 || ^4.0" + }, + "peerDependenciesMeta": { + "@cfworker/json-schema": { + "optional": true + }, + "zod": { + "optional": false + } + } + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.55.1.tgz", + "integrity": "sha512-9R0DM/ykwfGIlNu6+2U09ga0WXeZ9MRC2Ter8jnz8415VbuIykVuc6bhdrbORFZANDmTDvq26mJrEVTl8TdnDg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.55.1.tgz", + "integrity": "sha512-eFZCb1YUqhTysgW3sj/55du5cG57S7UTNtdMjCW7LwVcj3dTTcowCsC8p7uBdzKsZYa8J7IDE8lhMI+HX1vQvg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.55.1.tgz", + "integrity": "sha512-p3grE2PHcQm2e8PSGZdzIhCKbMCw/xi9XvMPErPhwO17vxtvCN5FEA2mSLgmKlCjHGMQTP6phuQTYWUnKewwGg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.55.1.tgz", + "integrity": "sha512-rDUjG25C9qoTm+e02Esi+aqTKSBYwVTaoS1wxcN47/Luqef57Vgp96xNANwt5npq9GDxsH7kXxNkJVEsWEOEaQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.55.1.tgz", + "integrity": "sha512-+JiU7Jbp5cdxekIgdte0jfcu5oqw4GCKr6i3PJTlXTCU5H5Fvtkpbs4XJHRmWNXF+hKmn4v7ogI5OQPaupJgOg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.55.1.tgz", + "integrity": "sha512-V5xC1tOVWtLLmr3YUk2f6EJK4qksksOYiz/TCsFHu/R+woubcLWdC9nZQmwjOAbmExBIVKsm1/wKmEy4z4u4Bw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.55.1.tgz", + "integrity": "sha512-Rn3n+FUk2J5VWx+ywrG/HGPTD9jXNbicRtTM11e/uorplArnXZYsVifnPPqNNP5BsO3roI4n8332ukpY/zN7rQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.55.1.tgz", + "integrity": "sha512-grPNWydeKtc1aEdrJDWk4opD7nFtQbMmV7769hiAaYyUKCT1faPRm2av8CX1YJsZ4TLAZcg9gTR1KvEzoLjXkg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.55.1.tgz", + "integrity": "sha512-a59mwd1k6x8tXKcUxSyISiquLwB5pX+fJW9TkWU46lCqD/GRDe9uDN31jrMmVP3feI3mhAdvcCClhV8V5MhJFQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.55.1.tgz", + "integrity": "sha512-puS1MEgWX5GsHSoiAsF0TYrpomdvkaXm0CofIMG5uVkP6IBV+ZO9xhC5YEN49nsgYo1DuuMquF9+7EDBVYu4uA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.55.1.tgz", + "integrity": "sha512-r3Wv40in+lTsULSb6nnoudVbARdOwb2u5fpeoOAZjFLznp6tDU8kd+GTHmJoqZ9lt6/Sys33KdIHUaQihFcu7g==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.55.1.tgz", + "integrity": "sha512-MR8c0+UxAlB22Fq4R+aQSPBayvYa3+9DrwG/i1TKQXFYEaoW3B5b/rkSRIypcZDdWjWnpcvxbNaAJDcSbJU3Lw==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.55.1.tgz", + "integrity": "sha512-3KhoECe1BRlSYpMTeVrD4sh2Pw2xgt4jzNSZIIPLFEsnQn9gAnZagW9+VqDqAHgm1Xc77LzJOo2LdigS5qZ+gw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.55.1.tgz", + "integrity": "sha512-ziR1OuZx0vdYZZ30vueNZTg73alF59DicYrPViG0NEgDVN8/Jl87zkAPu4u6VjZST2llgEUjaiNl9JM6HH1Vdw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.55.1.tgz", + "integrity": "sha512-uW0Y12ih2XJRERZ4jAfKamTyIHVMPQnTZcQjme2HMVDAHY4amf5u414OqNYC+x+LzRdRcnIG1YodLrrtA8xsxw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.55.1.tgz", + "integrity": "sha512-u9yZ0jUkOED1BFrqu3BwMQoixvGHGZ+JhJNkNKY/hyoEgOwlqKb62qu+7UjbPSHYjiVy8kKJHvXKv5coH4wDeg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.55.1.tgz", + "integrity": "sha512-/0PenBCmqM4ZUd0190j7J0UsQ/1nsi735iPRakO8iPciE7BQ495Y6msPzaOmvx0/pn+eJVVlZrNrSh4WSYLxNg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.55.1.tgz", + "integrity": "sha512-a8G4wiQxQG2BAvo+gU6XrReRRqj+pLS2NGXKm8io19goR+K8lw269eTrPkSdDTALwMmJp4th2Uh0D8J9bEV1vg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.55.1.tgz", + "integrity": "sha512-bD+zjpFrMpP/hqkfEcnjXWHMw5BIghGisOKPj+2NaNDuVT+8Ds4mPf3XcPHuat1tz89WRL+1wbcxKY3WSbiT7w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.55.1.tgz", + "integrity": "sha512-eLXw0dOiqE4QmvikfQ6yjgkg/xDM+MdU9YJuP4ySTibXU0oAvnEWXt7UDJmD4UkYialMfOGFPJnIHSe/kdzPxg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.55.1.tgz", + "integrity": "sha512-xzm44KgEP11te3S2HCSyYf5zIzWmx3n8HDCc7EE59+lTcswEWNpvMLfd9uJvVX8LCg9QWG67Xt75AuHn4vgsXw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.55.1.tgz", + "integrity": "sha512-yR6Bl3tMC/gBok5cz/Qi0xYnVbIxGx5Fcf/ca0eB6/6JwOY+SRUcJfI0OpeTpPls7f194as62thCt/2BjxYN8g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.55.1.tgz", + "integrity": "sha512-3fZBidchE0eY0oFZBnekYCfg+5wAB0mbpCBuofh5mZuzIU/4jIVkbESmd2dOsFNS78b53CYv3OAtwqkZZmU5nA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.55.1.tgz", + "integrity": "sha512-xGGY5pXj69IxKb4yv/POoocPy/qmEGhimy/FoTpTSVju3FYXUQQMFCaZZXJVidsmGxRioZAwpThl/4zX41gRKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.55.1.tgz", + "integrity": "sha512-SPEpaL6DX4rmcXtnhdrQYgzQ5W2uW3SCJch88lB2zImhJRhIIK44fkUrgIV/Q8yUNfw5oyZ5vkeQsZLhCb06lw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@supabase/auth-js": { + "version": "2.90.1", + "resolved": "https://registry.npmjs.org/@supabase/auth-js/-/auth-js-2.90.1.tgz", + "integrity": "sha512-vxb66dgo6h3yyPbR06735Ps+dK3hj0JwS8w9fdQPVZQmocSTlKUW5MfxSy99mN0XqCCuLMQ3jCEiIIUU23e9ng==", + "license": "MIT", + "dependencies": { + "tslib": "2.8.1" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@supabase/functions-js": { + "version": "2.90.1", + "resolved": "https://registry.npmjs.org/@supabase/functions-js/-/functions-js-2.90.1.tgz", + "integrity": "sha512-x9mV9dF1Lam9qL3zlpP6mSM5C9iqMPtF5B/tU1Jj/F0ufX5mjDf9ghVBaErVxmrQJRL4+iMKWKY2GnODkpS8tw==", + "license": "MIT", + "dependencies": { + "tslib": "2.8.1" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@supabase/postgrest-js": { + "version": "2.90.1", + "resolved": "https://registry.npmjs.org/@supabase/postgrest-js/-/postgrest-js-2.90.1.tgz", + "integrity": "sha512-jh6vqzaYzoFn3raaC0hcFt9h+Bt+uxNRBSdc7PfToQeRGk7PDPoweHsbdiPWREtDVTGKfu+PyPW9e2jbK+BCgQ==", + "license": "MIT", + "dependencies": { + "tslib": "2.8.1" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@supabase/realtime-js": { + "version": "2.90.1", + "resolved": "https://registry.npmjs.org/@supabase/realtime-js/-/realtime-js-2.90.1.tgz", + "integrity": "sha512-PWbnEMkcQRuor8jhObp4+Snufkq8C6fBp+MchVp2qBPY1NXk/c3Iv3YyiFYVzo0Dzuw4nAlT4+ahuPggy4r32w==", + "license": "MIT", + "dependencies": { + "@types/phoenix": "^1.6.6", + "@types/ws": "^8.18.1", + "tslib": "2.8.1", + "ws": "^8.18.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@supabase/storage-js": { + "version": "2.90.1", + "resolved": "https://registry.npmjs.org/@supabase/storage-js/-/storage-js-2.90.1.tgz", + "integrity": "sha512-GHY+Ps/K/RBfRj7kwx+iVf2HIdqOS43rM2iDOIDpapyUnGA9CCBFzFV/XvfzznGykd//z2dkGZhlZZprsVFqGg==", + "license": "MIT", + "dependencies": { + "iceberg-js": "^0.8.1", + "tslib": "2.8.1" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@supabase/supabase-js": { + "version": "2.90.1", + "resolved": "https://registry.npmjs.org/@supabase/supabase-js/-/supabase-js-2.90.1.tgz", + "integrity": "sha512-U8KaKGLUgTIFHtwEW1dgw1gK7XrdpvvYo7nzzqPx721GqPe8WZbAiLh/hmyKLGBYQ/mmQNr20vU9tWSDZpii3w==", + "license": "MIT", + "dependencies": { + "@supabase/auth-js": "2.90.1", + "@supabase/functions-js": "2.90.1", + "@supabase/postgrest-js": "2.90.1", + "@supabase/realtime-js": "2.90.1", + "@supabase/storage-js": "2.90.1" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "22.19.5", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.5.tgz", + "integrity": "sha512-HfF8+mYcHPcPypui3w3mvzuIErlNOh2OAG+BCeBZCEwyiD5ls2SiCwEyT47OELtf7M3nHxBdu0FsmzdKxkN52Q==", + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/phoenix": { + "version": "1.6.7", + "resolved": "https://registry.npmjs.org/@types/phoenix/-/phoenix-1.6.7.tgz", + "integrity": "sha512-oN9ive//QSBkf19rfDv45M7eZPi0eEXylht2OLEXicu5b4KoQ1OzXIw+xDSGWxSxe1JmepRR/ZH283vsu518/Q==", + "license": "MIT" + }, + "node_modules/@types/ws": { + "version": "8.18.1", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", + "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/accepts": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", + "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", + "license": "MIT", + "dependencies": { + "mime-types": "^3.0.0", + "negotiator": "^1.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/acorn": { + "version": "8.15.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", + "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/ajv": { + "version": "8.17.1", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", + "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-formats": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", + "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", + "license": "MIT", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/any-promise": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", + "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==", + "dev": true, + "license": "MIT" + }, + "node_modules/body-parser": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz", + "integrity": "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==", + "license": "MIT", + "dependencies": { + "bytes": "^3.1.2", + "content-type": "^1.0.5", + "debug": "^4.4.3", + "http-errors": "^2.0.0", + "iconv-lite": "^0.7.0", + "on-finished": "^2.4.1", + "qs": "^6.14.1", + "raw-body": "^3.0.1", + "type-is": "^2.0.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/bundle-require": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/bundle-require/-/bundle-require-5.1.0.tgz", + "integrity": "sha512-3WrrOuZiyaaZPWiEt4G3+IffISVC9HYlWueJEBWED4ZH4aIAC2PnkdnuRrR94M+w6yGWn4AglWtJtBI8YqvgoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "load-tsconfig": "^0.2.3" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "peerDependencies": { + "esbuild": ">=0.18" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/chokidar": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", + "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "readdirp": "^4.0.1" + }, + "engines": { + "node": ">= 14.16.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/commander": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", + "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/confbox": { + "version": "0.1.8", + "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.1.8.tgz", + "integrity": "sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/consola": { + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/consola/-/consola-3.4.2.tgz", + "integrity": "sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^14.18.0 || >=16.10.0" + } + }, + "node_modules/content-disposition": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.1.tgz", + "integrity": "sha512-oIXISMynqSqm241k6kcQ5UwttDILMK4BiurCfGEREw6+X9jkkpEe5T9FZaApyLGGOnFuyMWZpdolTXMtvEJ08Q==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "license": "MIT", + "engines": { + "node": ">=6.6.0" + } + }, + "node_modules/cors": { + "version": "2.8.5", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.5.tgz", + "integrity": "sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g==", + "license": "MIT", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/dotenv": { + "version": "16.6.1", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", + "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/esbuild": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.2.tgz", + "integrity": "sha512-HyNQImnsOC7X9PMNaCIeAm4ISCQXs5a5YasTXVliKv4uuBo1dKrG0A+uQS8M5eXjVMnLg3WgXaKvprHlFJQffw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "peer": true, + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.2", + "@esbuild/android-arm": "0.27.2", + "@esbuild/android-arm64": "0.27.2", + "@esbuild/android-x64": "0.27.2", + "@esbuild/darwin-arm64": "0.27.2", + "@esbuild/darwin-x64": "0.27.2", + "@esbuild/freebsd-arm64": "0.27.2", + "@esbuild/freebsd-x64": "0.27.2", + "@esbuild/linux-arm": "0.27.2", + "@esbuild/linux-arm64": "0.27.2", + "@esbuild/linux-ia32": "0.27.2", + "@esbuild/linux-loong64": "0.27.2", + "@esbuild/linux-mips64el": "0.27.2", + "@esbuild/linux-ppc64": "0.27.2", + "@esbuild/linux-riscv64": "0.27.2", + "@esbuild/linux-s390x": "0.27.2", + "@esbuild/linux-x64": "0.27.2", + "@esbuild/netbsd-arm64": "0.27.2", + "@esbuild/netbsd-x64": "0.27.2", + "@esbuild/openbsd-arm64": "0.27.2", + "@esbuild/openbsd-x64": "0.27.2", + "@esbuild/openharmony-arm64": "0.27.2", + "@esbuild/sunos-x64": "0.27.2", + "@esbuild/win32-arm64": "0.27.2", + "@esbuild/win32-ia32": "0.27.2", + "@esbuild/win32-x64": "0.27.2" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/eventsource": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz", + "integrity": "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==", + "license": "MIT", + "dependencies": { + "eventsource-parser": "^3.0.1" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/eventsource-parser": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.0.6.tgz", + "integrity": "sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/express": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", + "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", + "license": "MIT", + "peer": true, + "dependencies": { + "accepts": "^2.0.0", + "body-parser": "^2.2.1", + "content-disposition": "^1.0.0", + "content-type": "^1.0.5", + "cookie": "^0.7.1", + "cookie-signature": "^1.2.1", + "debug": "^4.4.0", + "depd": "^2.0.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "finalhandler": "^2.1.0", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", + "merge-descriptors": "^2.0.0", + "mime-types": "^3.0.0", + "on-finished": "^2.4.1", + "once": "^1.4.0", + "parseurl": "^1.3.3", + "proxy-addr": "^2.0.7", + "qs": "^6.14.0", + "range-parser": "^1.2.1", + "router": "^2.2.0", + "send": "^1.1.0", + "serve-static": "^2.2.0", + "statuses": "^2.0.1", + "type-is": "^2.0.1", + "vary": "^1.1.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/express-rate-limit": { + "version": "7.5.1", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-7.5.1.tgz", + "integrity": "sha512-7iN8iPMDzOMHPUYllBEsQdWVB6fPDMPqwjBaFrgr4Jgr/+okjvzAy+UHlYYL/Vs0OsOrMkwS6PJDkFlJwoxUnw==", + "license": "MIT", + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/express-rate-limit" + }, + "peerDependencies": { + "express": ">= 4.11" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "license": "MIT" + }, + "node_modules/fast-uri": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz", + "integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/finalhandler": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", + "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "on-finished": "^2.4.1", + "parseurl": "^1.3.3", + "statuses": "^2.0.1" + }, + "engines": { + "node": ">= 18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/fix-dts-default-cjs-exports": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/fix-dts-default-cjs-exports/-/fix-dts-default-cjs-exports-1.0.1.tgz", + "integrity": "sha512-pVIECanWFC61Hzl2+oOCtoJ3F17kglZC/6N94eRWycFgBH35hHx0Li604ZIzhseh97mf2p0cv7vVrOZGoqhlEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "magic-string": "^0.30.17", + "mlly": "^1.7.4", + "rollup": "^4.34.8" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", + "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/get-tsconfig": { + "version": "4.13.0", + "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.0.tgz", + "integrity": "sha512-1VKTZJCwBrvbd+Wn3AOgQP/2Av+TfTCOlE4AcRJE72W1ksZXbAx8PPBR9RzgTeSPzlPMHrbANMH3LbltH73wxQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "resolve-pkg-maps": "^1.0.0" + }, + "funding": { + "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/hono": { + "version": "4.11.3", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.11.3.tgz", + "integrity": "sha512-PmQi306+M/ct/m5s66Hrg+adPnkD5jiO6IjA7WhWw0gSBSo1EcRegwuI1deZ+wd5pzCGynCcn2DprnE4/yEV4w==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=16.9.0" + } + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/iceberg-js": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/iceberg-js/-/iceberg-js-0.8.1.tgz", + "integrity": "sha512-1dhVQZXhcHje7798IVM+xoo/1ZdVfzOMIc8/rgVSijRK38EDqOJoGula9N/8ZI5RD8QTxNQtK/Gozpr+qUqRRA==", + "license": "MIT", + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/iconv-lite": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", + "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/is-promise": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", + "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", + "license": "MIT" + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "license": "ISC" + }, + "node_modules/jose": { + "version": "6.1.3", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.1.3.tgz", + "integrity": "sha512-0TpaTfihd4QMNwrz/ob2Bp7X04yuxJkjRGi4aKmOqwhov54i6u79oCv7T+C7lo70MKH6BesI3vscD1yb/yzKXQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, + "node_modules/joycon": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/joycon/-/joycon-3.1.1.tgz", + "integrity": "sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT" + }, + "node_modules/json-schema-typed": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/json-schema-typed/-/json-schema-typed-8.0.2.tgz", + "integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==", + "license": "BSD-2-Clause" + }, + "node_modules/lilconfig": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", + "integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antonk52" + } + }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "dev": true, + "license": "MIT" + }, + "node_modules/load-tsconfig": { + "version": "0.2.5", + "resolved": "https://registry.npmjs.org/load-tsconfig/-/load-tsconfig-0.2.5.tgz", + "integrity": "sha512-IXO6OCs9yg8tMKzfPZ1YmheJbZCiEsnBdcB03l0OcfK9prKnJb96siuHCr5Fl37/yo9DnKU+TLpxzTUspw9shg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", + "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/merge-descriptors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", + "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/mlly": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/mlly/-/mlly-1.8.0.tgz", + "integrity": "sha512-l8D9ODSRWLe2KHJSifWGwBqpTZXIXTeo8mlKjY+E2HAakaTeNpqAyBZ8GSqLzHgw4XmHmC8whvpjJNMbFZN7/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "acorn": "^8.15.0", + "pathe": "^2.0.3", + "pkg-types": "^1.3.1", + "ufo": "^1.6.1" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/mz": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", + "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0", + "object-assign": "^4.0.1", + "thenify-all": "^1.0.0" + } + }, + "node_modules/negotiator": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", + "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-to-regexp": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.3.0.tgz", + "integrity": "sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pirates": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz", + "integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/pkce-challenge": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.1.tgz", + "integrity": "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==", + "license": "MIT", + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/pkg-types": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-1.3.1.tgz", + "integrity": "sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "confbox": "^0.1.8", + "mlly": "^1.7.4", + "pathe": "^2.0.1" + } + }, + "node_modules/postcss-load-config": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-6.0.1.tgz", + "integrity": "sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "lilconfig": "^3.1.1" + }, + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "jiti": ">=1.21.0", + "postcss": ">=8.0.9", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + }, + "postcss": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/qs": { + "version": "6.14.1", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.1.tgz", + "integrity": "sha512-4EK3+xJl8Ts67nLYNwqw/dsFVnCf+qR7RgXSK9jEEm9unao3njwMDdmsdvoKBKHzxd7tCYz5e5M+SnMjdtXGQQ==", + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", + "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.7.0", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/readdirp": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", + "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.18.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/resolve-from": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", + "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/resolve-pkg-maps": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", + "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" + } + }, + "node_modules/rollup": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.55.1.tgz", + "integrity": "sha512-wDv/Ht1BNHB4upNbK74s9usvl7hObDnvVzknxqY/E/O3X6rW1U1rV1aENEfJ54eFZDTNo7zv1f5N4edCluH7+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.55.1", + "@rollup/rollup-android-arm64": "4.55.1", + "@rollup/rollup-darwin-arm64": "4.55.1", + "@rollup/rollup-darwin-x64": "4.55.1", + "@rollup/rollup-freebsd-arm64": "4.55.1", + "@rollup/rollup-freebsd-x64": "4.55.1", + "@rollup/rollup-linux-arm-gnueabihf": "4.55.1", + "@rollup/rollup-linux-arm-musleabihf": "4.55.1", + "@rollup/rollup-linux-arm64-gnu": "4.55.1", + "@rollup/rollup-linux-arm64-musl": "4.55.1", + "@rollup/rollup-linux-loong64-gnu": "4.55.1", + "@rollup/rollup-linux-loong64-musl": "4.55.1", + "@rollup/rollup-linux-ppc64-gnu": "4.55.1", + "@rollup/rollup-linux-ppc64-musl": "4.55.1", + "@rollup/rollup-linux-riscv64-gnu": "4.55.1", + "@rollup/rollup-linux-riscv64-musl": "4.55.1", + "@rollup/rollup-linux-s390x-gnu": "4.55.1", + "@rollup/rollup-linux-x64-gnu": "4.55.1", + "@rollup/rollup-linux-x64-musl": "4.55.1", + "@rollup/rollup-openbsd-x64": "4.55.1", + "@rollup/rollup-openharmony-arm64": "4.55.1", + "@rollup/rollup-win32-arm64-msvc": "4.55.1", + "@rollup/rollup-win32-ia32-msvc": "4.55.1", + "@rollup/rollup-win32-x64-gnu": "4.55.1", + "@rollup/rollup-win32-x64-msvc": "4.55.1", + "fsevents": "~2.3.2" + } + }, + "node_modules/router": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", + "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "depd": "^2.0.0", + "is-promise": "^4.0.0", + "parseurl": "^1.3.3", + "path-to-regexp": "^8.0.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", + "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "fresh": "^2.0.0", + "http-errors": "^2.0.1", + "mime-types": "^3.0.2", + "ms": "^2.1.3", + "on-finished": "^2.4.1", + "range-parser": "^1.2.1", + "statuses": "^2.0.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/serve-static": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz", + "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==", + "license": "MIT", + "dependencies": { + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "parseurl": "^1.3.3", + "send": "^1.2.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/side-channel": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", + "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/source-map": { + "version": "0.7.6", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.6.tgz", + "integrity": "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">= 12" + } + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/sucrase": { + "version": "3.35.1", + "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz", + "integrity": "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.2", + "commander": "^4.0.0", + "lines-and-columns": "^1.1.6", + "mz": "^2.7.0", + "pirates": "^4.0.1", + "tinyglobby": "^0.2.11", + "ts-interface-checker": "^0.1.9" + }, + "bin": { + "sucrase": "bin/sucrase", + "sucrase-node": "bin/sucrase-node" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/thenify": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", + "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0" + } + }, + "node_modules/thenify-all": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz", + "integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "thenify": ">= 3.1.0 < 4" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/tinyexec": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", + "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/tree-kill": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz", + "integrity": "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==", + "dev": true, + "license": "MIT", + "bin": { + "tree-kill": "cli.js" + } + }, + "node_modules/ts-interface-checker": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", + "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/tsup": { + "version": "8.5.1", + "resolved": "https://registry.npmjs.org/tsup/-/tsup-8.5.1.tgz", + "integrity": "sha512-xtgkqwdhpKWr3tKPmCkvYmS9xnQK3m3XgxZHwSUjvfTjp7YfXe5tT3GgWi0F2N+ZSMsOeWeZFh7ZZFg5iPhing==", + "dev": true, + "license": "MIT", + "dependencies": { + "bundle-require": "^5.1.0", + "cac": "^6.7.14", + "chokidar": "^4.0.3", + "consola": "^3.4.0", + "debug": "^4.4.0", + "esbuild": "^0.27.0", + "fix-dts-default-cjs-exports": "^1.0.0", + "joycon": "^3.1.1", + "picocolors": "^1.1.1", + "postcss-load-config": "^6.0.1", + "resolve-from": "^5.0.0", + "rollup": "^4.34.8", + "source-map": "^0.7.6", + "sucrase": "^3.35.0", + "tinyexec": "^0.3.2", + "tinyglobby": "^0.2.11", + "tree-kill": "^1.2.2" + }, + "bin": { + "tsup": "dist/cli-default.js", + "tsup-node": "dist/cli-node.js" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@microsoft/api-extractor": "^7.36.0", + "@swc/core": "^1", + "postcss": "^8.4.12", + "typescript": ">=4.5.0" + }, + "peerDependenciesMeta": { + "@microsoft/api-extractor": { + "optional": true + }, + "@swc/core": { + "optional": true + }, + "postcss": { + "optional": true + }, + "typescript": { + "optional": true + } + } + }, + "node_modules/tsx": { + "version": "4.21.0", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz", + "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "esbuild": "~0.27.0", + "get-tsconfig": "^4.7.5" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/type-is": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz", + "integrity": "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==", + "license": "MIT", + "dependencies": { + "content-type": "^1.0.5", + "media-typer": "^1.1.0", + "mime-types": "^3.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "peer": true, + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/ufo": { + "version": "1.6.2", + "resolved": "https://registry.npmjs.org/ufo/-/ufo-1.6.2.tgz", + "integrity": "sha512-heMioaxBcG9+Znsda5Q8sQbWnLJSl98AFDXTO80wELWEzX3hordXsTdxrIfMQoO9IY1MEnoGoPjpoKpMj+Yx0Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "license": "MIT" + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" + }, + "node_modules/ws": { + "version": "8.19.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz", + "integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "license": "MIT", + "peer": true, + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zod-to-json-schema": { + "version": "3.25.1", + "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.1.tgz", + "integrity": "sha512-pM/SU9d3YAggzi6MtR4h7ruuQlqKtad8e9S0fmxcMi+ueAK5Korys/aWcV9LIIHTVbj01NdzxcnXSN+O74ZIVA==", + "license": "ISC", + "peerDependencies": { + "zod": "^3.25 || ^4" + } + } + } +} diff --git a/apps/mcp/package.json b/apps/mcp/package.json new file mode 100644 index 0000000..1ce8bb9 --- /dev/null +++ b/apps/mcp/package.json @@ -0,0 +1,25 @@ +{ + "name": "sauci-mcp-server", + "version": "0.1.0", + "private": true, + "scripts": { + "dev": "tsx watch src/index.ts", + "build": "tsup src/index.ts --format esm --clean", + "start": "node dist/index.js", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@hono/mcp": "^0.1.1", + "@modelcontextprotocol/sdk": "^1.0.1", + "@supabase/supabase-js": "^2.45.0", + "hono": "^4.6.0", + "zod": "^3.23.0", + "dotenv": "^16.4.5" + }, + "devDependencies": { + "@types/node": "^22.0.0", + "tsx": "^4.19.0", + "tsup": "^8.2.4", + "typescript": "^5.5.0" + } +} diff --git a/apps/mcp/src/index.ts b/apps/mcp/src/index.ts new file mode 100644 index 0000000..eceab68 --- /dev/null +++ b/apps/mcp/src/index.ts @@ -0,0 +1,28 @@ +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.get('/health', (c) => c.json({ status: 'ok', service: 'sauci-mcp' })); + +// Initialize MCP server +const mcpServer = createMcpServer(); + +// Mount MCP server +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 +}); diff --git a/apps/mcp/src/lib/auth.ts b/apps/mcp/src/lib/auth.ts new file mode 100644 index 0000000..5fbea51 --- /dev/null +++ b/apps/mcp/src/lib/auth.ts @@ -0,0 +1,25 @@ +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(); + } + + if (!authHeader || !authHeader.startsWith('Bearer ')) { + return c.json({ error: 'Unauthorized: Missing or invalid Authorization header' }, 401); + } + + const token = authHeader.replace('Bearer ', ''); + + if (token !== apiKey) { + return c.json({ error: 'Unauthorized: Invalid API key' }, 401); + } + + return next(); +}; diff --git a/apps/mcp/src/lib/supabase.ts b/apps/mcp/src/lib/supabase.ts new file mode 100644 index 0000000..bebe3c0 --- /dev/null +++ b/apps/mcp/src/lib/supabase.ts @@ -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 + } + } +); diff --git a/apps/mcp/src/server.ts b/apps/mcp/src/server.ts new file mode 100644 index 0000000..b89f4f6 --- /dev/null +++ b/apps/mcp/src/server.ts @@ -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); + } + }; +} diff --git a/apps/mcp/src/tools/admin.ts b/apps/mcp/src/tools/admin.ts new file mode 100644 index 0000000..a0b8f6b --- /dev/null +++ b/apps/mcp/src/tools/admin.ts @@ -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) }] + }; + } + ); +} diff --git a/apps/mcp/src/tools/analytics.ts b/apps/mcp/src/tools/analytics.ts new file mode 100644 index 0000000..c93d944 --- /dev/null +++ b/apps/mcp/src/tools/analytics.ts @@ -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 = {}; + 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 = {}; + const genders: Record = {}; + + 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." }] + }; + } + ); +} diff --git a/apps/mcp/src/tools/codes.ts b/apps/mcp/src/tools/codes.ts new file mode 100644 index 0000000..3b06d02 --- /dev/null +++ b/apps/mcp/src/tools/codes.ts @@ -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) }] + }; + } + ); +} diff --git a/apps/mcp/src/tools/config.ts b/apps/mcp/src/tools/config.ts new file mode 100644 index 0000000..b9018be --- /dev/null +++ b/apps/mcp/src/tools/config.ts @@ -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) }] + }; + } + ); +} diff --git a/apps/mcp/src/tools/content.ts b/apps/mcp/src/tools/content.ts new file mode 100644 index 0000000..160a92c --- /dev/null +++ b/apps/mcp/src/tools/content.ts @@ -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` }] + }; + } + ); +} diff --git a/apps/mcp/src/tools/feedback.ts b/apps/mcp/src/tools/feedback.ts new file mode 100644 index 0000000..0b20bb7 --- /dev/null +++ b/apps/mcp/src/tools/feedback.ts @@ -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}` }] + }; + } + ); +} diff --git a/apps/mcp/src/tools/moderation.ts b/apps/mcp/src/tools/moderation.ts new file mode 100644 index 0000000..6e5b999 --- /dev/null +++ b/apps/mcp/src/tools/moderation.ts @@ -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` }] + }; + } + ); +} diff --git a/apps/mcp/src/tools/users.ts b/apps/mcp/src/tools/users.ts new file mode 100644 index 0000000..a017a02 --- /dev/null +++ b/apps/mcp/src/tools/users.ts @@ -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()}` }] + }; + } + ); +} diff --git a/apps/mcp/src/utils/audit.ts b/apps/mcp/src/utils/audit.ts new file mode 100644 index 0000000..8724b78 --- /dev/null +++ b/apps/mcp/src/utils/audit.ts @@ -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; + new_values?: Record; + admin_user_id?: string; +} + +let cachedSystemAdminId: string | null = null; + +async function getSystemAdminId(): Promise { + 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); + } +} diff --git a/apps/mcp/tsconfig.json b/apps/mcp/tsconfig.json new file mode 100644 index 0000000..68c17db --- /dev/null +++ b/apps/mcp/tsconfig.json @@ -0,0 +1,12 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "Bundler", + "strict": true, + "skipLibCheck": true, + "esModuleInterop": true, + "outDir": "dist" + }, + "include": ["src/**/*"] +} diff --git a/apps/mobile/app/(app)/packs/questions.tsx b/apps/mobile/app/(app)/packs/questions.tsx index ca96a79..6d56cf1 100644 --- a/apps/mobile/app/(app)/packs/questions.tsx +++ b/apps/mobile/app/(app)/packs/questions.tsx @@ -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 - {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 ( + + ); + })} {intensityDisplay.label} @@ -462,7 +467,15 @@ export default function QuestionPacksScreen() { {(packsByCategory.get("uncategorized")?.length || 0) > 0 && ( = [ - { 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 = [ diff --git a/apps/mobile/src/store/packsStore.ts b/apps/mobile/src/store/packsStore.ts index b65e28b..d763762 100644 --- a/apps/mobile/src/store/packsStore.ts +++ b/apps/mobile/src/store/packsStore.ts @@ -45,36 +45,47 @@ export const usePacksStore = create((set, get) => ({ const maxIntensity = getUserMaxIntensity(); - // Fetch categories - const { data: categories } = await supabase - .from("categories") - .select("*") - .order("sort_order"); - - // Fetch packs with category info and question count - let query = supabase - .from("question_packs") - .select("*, category:categories(*), questions(count)") - .eq("is_public", true) - .order("sort_order"); - + // Fetch categories + const { data: categories } = await supabase + .from("categories") + .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") + .select("*, category:categories(*), questions(count)") + .eq("is_public", true) + .order("sort_order"); + // Filter out packs above user's intensity preference if (maxIntensity < 5) { query = query.or(`max_intensity.is.null,max_intensity.lte.${maxIntensity}`); } - - const { data: packs } = await query; - - // Also fetch enabled packs if logged in - await get().fetchEnabledPacks(); - - set({ - packs: packs ?? [], - categories: categories ?? [], - isLoading: false - }); - }, + 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: visiblePacks, + categories: visibleCategories, + isLoading: false + }); + }, fetchEnabledPacks: async () => { const coupleId = useAuthStore.getState().user?.couple_id; diff --git a/apps/mobile/src/store/responsesStore.ts b/apps/mobile/src/store/responsesStore.ts index e879c40..50365ef 100644 --- a/apps/mobile/src/store/responsesStore.ts +++ b/apps/mobile/src/store/responsesStore.ts @@ -93,7 +93,7 @@ export const useResponsesStore = create((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((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) diff --git a/apps/mobile/src/types/index.ts b/apps/mobile/src/types/index.ts index 8cd7c49..bad2443 100755 --- a/apps/mobile/src/types/index.ts +++ b/apps/mobile/src/types/index.ts @@ -28,14 +28,16 @@ export interface Couple { } // Category for grouping packs -export interface Category { - id: string; - name: string; - description: string | null; - icon: string | null; - sort_order: number; - created_at: string; -} +export interface Category { + id: string; + name: string; + description: string | null; + icon: string | null; + sort_order: number; + created_at: string; + is_public: boolean; +} + // Question pack export interface QuestionPack { diff --git a/apps/mobile/src/types/supabase.ts b/apps/mobile/src/types/supabase.ts index f3a2431..3aed108 100755 --- a/apps/mobile/src/types/supabase.ts +++ b/apps/mobile/src/types/supabase.ts @@ -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", diff --git a/apps/supabase/migrations/20260110000000_add_category_visibility.sql b/apps/supabase/migrations/20260110000000_add_category_visibility.sql new file mode 100644 index 0000000..4c575ff --- /dev/null +++ b/apps/supabase/migrations/20260110000000_add_category_visibility.sql @@ -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; +$$; diff --git a/apps/supabase/migrations/20260110000002_add_temperature_control_to_ai_config.sql b/apps/supabase/migrations/20260110000002_add_temperature_control_to_ai_config.sql new file mode 100644 index 0000000..345ee5e --- /dev/null +++ b/apps/supabase/migrations/20260110000002_add_temperature_control_to_ai_config.sql @@ -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; diff --git a/apps/supabase/migrations/20260111000000_add_required_props_to_questions.sql b/apps/supabase/migrations/20260111000000_add_required_props_to_questions.sql new file mode 100644 index 0000000..ecf59fb --- /dev/null +++ b/apps/supabase/migrations/20260111000000_add_required_props_to_questions.sql @@ -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.'; diff --git a/docs/schema.md b/docs/schema.md index cb350f1..f1394b6 100644 --- a/docs/schema.md +++ b/docs/schema.md @@ -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 } diff --git a/packages/shared/src/types/index.ts b/packages/shared/src/types/index.ts index 18e0100..309ce7e 100755 --- a/packages/shared/src/types/index.ts +++ b/packages/shared/src/types/index.ts @@ -40,6 +40,7 @@ export interface Question { intensity: 1 | 2 | 3 | 4 | 5; allowed_couple_genders?: string[] | null; target_user_genders?: string[] | null; + required_props?: string[] | null; created_at: string; } diff --git a/packages/shared/src/types/supabase.ts b/packages/shared/src/types/supabase.ts index 69d9f60..3aed108 100755 --- a/packages/shared/src/types/supabase.ts +++ b/packages/shared/src/types/supabase.ts @@ -1,336 +1,1586 @@ -export type Json = - | string - | number - | boolean - | null - | { [key: string]: Json | undefined } - | Json[] - -export type Database = { - // Allows to automatically instantiate createClient with right options - // instead of createClient(URL, KEY) - __InternalSupabase: { - PostgrestVersion: "14.1" - } - public: { - Tables: { - couples: { - Row: { - created_at: string | null - id: string - invite_code: string | null - } - Insert: { - created_at?: string | null - id?: string - invite_code?: string | null - } - Update: { - created_at?: string | null - id?: string - invite_code?: string | null - } - Relationships: [] - } - matches: { - Row: { - couple_id: string - created_at: string | null - id: string - is_new: boolean | null - match_type: Database["public"]["Enums"]["match_type"] - question_id: string - } - Insert: { - couple_id: string - created_at?: string | null - id?: string - is_new?: boolean | null - match_type: Database["public"]["Enums"]["match_type"] - question_id: string - } - Update: { - couple_id?: string - created_at?: string | null - id?: string - is_new?: boolean | null - match_type?: Database["public"]["Enums"]["match_type"] - question_id?: string - } - Relationships: [ - { - foreignKeyName: "matches_couple_id_fkey" - columns: ["couple_id"] - isOneToOne: false - referencedRelation: "couples" - referencedColumns: ["id"] - }, - { - foreignKeyName: "matches_question_id_fkey" - columns: ["question_id"] - isOneToOne: false - referencedRelation: "questions" - referencedColumns: ["id"] - } - ] - } - profiles: { - Row: { - avatar_url: string | null - couple_id: string | null - created_at: string | null - id: string - is_premium: boolean | null - name: string | null - push_token: string | null - updated_at: string | null - } - Insert: { - avatar_url?: string | null - couple_id?: string | null - created_at?: string | null - id: string - is_premium?: boolean | null - name?: string | null - push_token?: string | null - updated_at?: string | null - } - Update: { - avatar_url?: string | null - couple_id?: string | null - created_at?: string | null - id?: string - is_premium?: boolean | null - name?: string | null - push_token?: string | null - updated_at?: string | null - } - Relationships: [ - { - foreignKeyName: "profiles_couple_id_fkey" - columns: ["couple_id"] - isOneToOne: false - referencedRelation: "couples" - referencedColumns: ["id"] - }, - { - foreignKeyName: "profiles_id_fkey" - columns: ["id"] - isOneToOne: true - referencedRelation: "users" - referencedColumns: ["id"] - } - ] - } - question_packs: { - Row: { - created_at: string | null - description: string | null - icon: string | null - id: string - is_premium: boolean | null - is_public: boolean | null - name: string - sort_order: number | null - } - Insert: { - created_at?: string | null - description?: string | null - icon?: string | null - id?: string - is_premium?: boolean | null - is_public?: boolean | null - name: string - sort_order?: number | null - } - Update: { - created_at?: string | null - description?: string | null - icon?: string | null - id?: string - is_premium?: boolean | null - is_public?: boolean | null - name?: string - sort_order?: number | null - } - Relationships: [] - } - questions: { - Row: { - created_at: string | null - id: string - intensity: number | null - pack_id: string - text: string - } - Insert: { - created_at?: string | null - id?: string - intensity?: number | null - pack_id: string - text: string - } - Update: { - created_at?: string | null - id?: string - intensity?: number | null - pack_id?: string - text?: string - } - Relationships: [ - { - foreignKeyName: "questions_pack_id_fkey" - columns: ["pack_id"] - isOneToOne: false - referencedRelation: "question_packs" - referencedColumns: ["id"] - } - ] - } - responses: { - Row: { - answer: Database["public"]["Enums"]["answer_type"] - couple_id: string - created_at: string | null - id: string - question_id: string - user_id: string - } - Insert: { - answer: Database["public"]["Enums"]["answer_type"] - couple_id: string - created_at?: string | null - id?: string - question_id: string - user_id: string - } - Update: { - answer?: Database["public"]["Enums"]["answer_type"] - couple_id?: string - created_at?: string | null - id?: string - question_id?: string - user_id?: string - } - Relationships: [ - { - foreignKeyName: "responses_couple_id_fkey" - columns: ["couple_id"] - isOneToOne: false - referencedRelation: "couples" - referencedColumns: ["id"] - }, - { - foreignKeyName: "responses_question_id_fkey" - columns: ["question_id"] - isOneToOne: false - referencedRelation: "questions" - referencedColumns: ["id"] - }, - { - foreignKeyName: "responses_user_id_fkey" - columns: ["user_id"] - isOneToOne: false - referencedRelation: "profiles" - referencedColumns: ["id"] - } - ] - } - } - Views: { - [_ in never]: never - } - Functions: { - [_ in never]: never - } - Enums: { - answer_type: "yes" | "no" | "maybe" - match_type: "yes_yes" | "yes_maybe" | "maybe_maybe" - } - CompositeTypes: { - [_ in never]: never - } - } -} - -type PublicSchema = Database[Extract] - -export type Tables< - PublicTableNameOrOptions extends - | keyof (PublicSchema["Tables"] & PublicSchema["Views"]) - | { schema: keyof Database }, - TableName extends PublicTableNameOrOptions extends { schema: keyof Database } - ? keyof (Database[PublicTableNameOrOptions["schema"]]["Tables"] & - Database[PublicTableNameOrOptions["schema"]]["Views"]) - : never = never, -> = PublicTableNameOrOptions extends { schema: keyof Database } - ? (Database[PublicTableNameOrOptions["schema"]]["Tables"] & - Database[PublicTableNameOrOptions["schema"]]["Views"])[TableName] extends { - Row: infer R - } - ? R - : never - : PublicTableNameOrOptions extends keyof (PublicSchema["Tables"] & - PublicSchema["Views"]) - ? (PublicSchema["Tables"] & - PublicSchema["Views"])[PublicTableNameOrOptions] extends { - Row: infer R - } - ? R - : never - : never - -export type TablesInsert< - PublicTableNameOrOptions extends - | keyof PublicSchema["Tables"] - | { schema: keyof Database }, - TableName extends PublicTableNameOrOptions extends { schema: keyof Database } - ? keyof Database[PublicTableNameOrOptions["schema"]]["Tables"] - : never = never, -> = PublicTableNameOrOptions extends { schema: keyof Database } - ? Database[PublicTableNameOrOptions["schema"]]["Tables"][TableName] extends { - Insert: infer I - } - ? I - : never - : PublicTableNameOrOptions extends keyof PublicSchema["Tables"] - ? PublicSchema["Tables"][PublicTableNameOrOptions] extends { - Insert: infer I - } - ? I - : never - : never - -export type TablesUpdate< - PublicTableNameOrOptions extends - | keyof PublicSchema["Tables"] - | { schema: keyof Database }, - TableName extends PublicTableNameOrOptions extends { schema: keyof Database } - ? keyof Database[PublicTableNameOrOptions["schema"]]["Tables"] - : never = never, -> = PublicTableNameOrOptions extends { schema: keyof Database } - ? Database[PublicTableNameOrOptions["schema"]]["Tables"][TableName] extends { - Update: infer U - } - ? U - : never - : PublicTableNameOrOptions extends keyof PublicSchema["Tables"] - ? PublicSchema["Tables"][PublicTableNameOrOptions] extends { - Update: infer U - } - ? U - : never - : never - -export type Enums< - PublicEnumNameOrOptions extends - | keyof PublicSchema["Enums"] - | { schema: keyof Database }, - EnumName extends PublicEnumNameOrOptions extends { schema: keyof Database } - ? keyof Database[PublicEnumNameOrOptions["schema"]]["Enums"] - : never = never, -> = PublicEnumNameOrOptions extends { schema: keyof Database } - ? Database[PublicEnumNameOrOptions["schema"]]["Enums"][EnumName] - : PublicEnumNameOrOptions extends keyof PublicSchema["Enums"] - ? PublicSchema["Enums"][PublicEnumNameOrOptions] - : never +export type Json = + | string + | number + | boolean + | null + | { [key: string]: Json | undefined } + | Json[] + +export type Database = { + // Allows to automatically instantiate createClient with right options + // instead of createClient(URL, KEY) + __InternalSupabase: { + PostgrestVersion: "14.1" + } + public: { + Tables: { + admin_users: { + Row: { + created_at: string | null + created_by: string | null + id: string + permissions: Json | null + role: Database["public"]["Enums"]["admin_role"] + updated_at: string | null + user_id: string + } + Insert: { + created_at?: string | null + created_by?: string | null + id?: string + permissions?: Json | null + role: Database["public"]["Enums"]["admin_role"] + updated_at?: string | null + user_id: string + } + Update: { + created_at?: string | null + created_by?: string | null + id?: string + permissions?: Json | null + role?: Database["public"]["Enums"]["admin_role"] + updated_at?: string | null + user_id?: string + } + Relationships: [] + } + 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 + } + Relationships: [] + } + app_config: { + Row: { + answer_gap_threshold: number | null + daily_response_limit: number | null + id: string + updated_at: string | null + updated_by: string | null + } + Insert: { + answer_gap_threshold?: number | null + daily_response_limit?: number | null + id?: string + updated_at?: string | null + updated_by?: string | null + } + Update: { + answer_gap_threshold?: number | null + daily_response_limit?: number | null + id?: string + updated_at?: string | null + updated_by?: string | null + } + Relationships: [] + } + audit_logs: { + Row: { + 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 + new_values: Json | null + old_values: Json | null + record_id: string + table_name: string + } + Insert: { + 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 + new_values?: Json | null + old_values?: Json | null + record_id: string + table_name: string + } + Update: { + 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 + new_values?: Json | null + old_values?: Json | null + record_id?: string + table_name?: string + } + Relationships: [] + } + categories: { + Row: { + created_at: string | null + description: string | null + icon: string | null + id: string + is_public: boolean + name: string + sort_order: number | null + } + Insert: { + created_at?: string | null + description?: string | null + icon?: string | null + id?: string + is_public?: boolean + name: string + sort_order?: number | null + } + Update: { + created_at?: string | null + description?: string | null + icon?: string | null + id?: string + is_public?: boolean + name?: string + sort_order?: number | null + } + Relationships: [] + } + code_redemptions: { + Row: { + code_id: string + id: string + redeemed_at: string | null + user_id: string + } + Insert: { + code_id: string + id?: string + redeemed_at?: string | null + user_id: string + } + Update: { + code_id?: string + id?: string + redeemed_at?: string | null + user_id?: string + } + Relationships: [ + { + foreignKeyName: "code_redemptions_code_id_fkey" + columns: ["code_id"] + isOneToOne: false + referencedRelation: "redemption_codes" + referencedColumns: ["id"] + }, + ] + } + couple_packs: { + Row: { + couple_id: string + created_at: string | null + enabled: boolean | null + pack_id: string + } + Insert: { + couple_id: string + created_at?: string | null + enabled?: boolean | null + pack_id: string + } + Update: { + couple_id?: string + created_at?: string | null + enabled?: boolean | null + pack_id?: string + } + Relationships: [ + { + foreignKeyName: "couple_packs_couple_id_fkey" + columns: ["couple_id"] + isOneToOne: false + referencedRelation: "couples" + referencedColumns: ["id"] + }, + { + foreignKeyName: "couple_packs_pack_id_fkey" + columns: ["pack_id"] + isOneToOne: false + referencedRelation: "question_packs" + referencedColumns: ["id"] + }, + ] + } + couples: { + Row: { + created_at: string | null + id: string + invite_code: string | null + } + Insert: { + created_at?: string | null + id?: string + invite_code?: string | null + } + Update: { + created_at?: string | null + id?: string + invite_code?: string | null + } + 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 + feature_name: string + id: string + user_id: string + } + Insert: { + created_at?: string + feature_name: string + id?: string + user_id: string + } + Update: { + created_at?: string + feature_name?: string + id?: string + user_id?: string + } + Relationships: [] + } + feedback: { + Row: { + admin_notes: string | null + created_at: string | null + description: string + device_info: Json + id: string + question_id: string | null + screenshot_url: string | null + status: Database["public"]["Enums"]["feedback_status"] + title: string + type: Database["public"]["Enums"]["feedback_type"] + updated_at: string | null + user_id: string + } + Insert: { + admin_notes?: string | null + created_at?: string | null + description: string + device_info?: Json + id?: string + question_id?: string | null + screenshot_url?: string | null + status?: Database["public"]["Enums"]["feedback_status"] + title: string + type: Database["public"]["Enums"]["feedback_type"] + updated_at?: string | null + user_id: string + } + Update: { + admin_notes?: string | null + created_at?: string | null + description?: string + device_info?: Json + id?: string + question_id?: string | null + screenshot_url?: string | null + status?: Database["public"]["Enums"]["feedback_status"] + title?: string + type?: Database["public"]["Enums"]["feedback_type"] + updated_at?: string | null + user_id?: string + } + Relationships: [ + { + foreignKeyName: "feedback_question_id_fkey" + columns: ["question_id"] + isOneToOne: false + referencedRelation: "questions" + referencedColumns: ["id"] + }, + { + foreignKeyName: "feedback_user_id_fkey" + columns: ["user_id"] + isOneToOne: false + referencedRelation: "profiles" + referencedColumns: ["id"] + }, + ] + } + master_keys: { + Row: { + created_at: string | null + id: string + is_active: boolean | null + key_name: string + public_key_jwk: Json + rotated_at: string | null + } + Insert: { + created_at?: string | null + id?: string + is_active?: boolean | null + key_name: string + public_key_jwk: Json + rotated_at?: string | null + } + Update: { + created_at?: string | null + id?: string + is_active?: boolean | null + key_name?: string + public_key_jwk?: Json + rotated_at?: string | null + } + Relationships: [] + } + match_archives: { + Row: { + archived_at: string | null + id: string + match_id: string + user_id: string + } + Insert: { + archived_at?: string | null + id?: string + match_id: string + user_id: string + } + Update: { + archived_at?: string | null + id?: string + match_id?: string + user_id?: string + } + Relationships: [ + { + foreignKeyName: "match_archives_match_id_fkey" + columns: ["match_id"] + isOneToOne: false + referencedRelation: "matches" + referencedColumns: ["id"] + }, + ] + } + matches: { + Row: { + couple_id: string + created_at: string | null + id: string + is_new: boolean | null + match_type: Database["public"]["Enums"]["match_type"] + question_id: string + } + Insert: { + couple_id: string + created_at?: string | null + id?: string + is_new?: boolean | null + match_type: Database["public"]["Enums"]["match_type"] + question_id: string + } + Update: { + couple_id?: string + created_at?: string | null + id?: string + is_new?: boolean | null + match_type?: Database["public"]["Enums"]["match_type"] + question_id?: string + } + Relationships: [ + { + foreignKeyName: "matches_couple_id_fkey" + columns: ["couple_id"] + isOneToOne: false + referencedRelation: "couples" + referencedColumns: ["id"] + }, + { + foreignKeyName: "matches_question_id_fkey" + columns: ["question_id"] + isOneToOne: false + referencedRelation: "questions" + referencedColumns: ["id"] + }, + ] + } + message_deletions: { + Row: { + deleted_at: string | null + id: string + message_id: string + user_id: string + } + Insert: { + deleted_at?: string | null + id?: string + message_id: string + user_id: string + } + Update: { + deleted_at?: string | null + id?: string + message_id?: string + user_id?: string + } + Relationships: [ + { + foreignKeyName: "message_deletions_message_id_fkey" + columns: ["message_id"] + isOneToOne: false + 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_reports_message_id_fkey" + columns: ["message_id"] + isOneToOne: false + 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 + media_expired: boolean | null + media_expires_at: string | null + 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 + media_expired?: boolean | null + media_expires_at?: string | null + 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 + media_expired?: boolean | null + media_expires_at?: string | null + 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 + } + Relationships: [ + { + foreignKeyName: "messages_match_id_fkey" + columns: ["match_id"] + isOneToOne: false + referencedRelation: "matches" + referencedColumns: ["id"] + }, + ] + } + pack_topics: { + Row: { + pack_id: string + topic_id: string + } + Insert: { + pack_id: string + topic_id: string + } + Update: { + pack_id?: string + topic_id?: string + } + Relationships: [ + { + foreignKeyName: "pack_topics_pack_id_fkey" + columns: ["pack_id"] + isOneToOne: false + referencedRelation: "question_packs" + referencedColumns: ["id"] + }, + { + foreignKeyName: "pack_topics_topic_id_fkey" + columns: ["topic_id"] + isOneToOne: false + referencedRelation: "topics" + referencedColumns: ["id"] + }, + ] + } + profiles: { + Row: { + avatar_url: string | null + couple_id: string | null + created_at: string | null + email: string | null + 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 + push_token: string | null + show_explicit_content: boolean | null + updated_at: string | null + usage_reason: string | null + } + Insert: { + avatar_url?: string | null + couple_id?: string | null + created_at?: string | null + email?: string | null + 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 + push_token?: string | null + show_explicit_content?: boolean | null + updated_at?: string | null + usage_reason?: string | null + } + Update: { + avatar_url?: string | null + couple_id?: string | null + created_at?: string | null + email?: string | null + 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 + push_token?: string | null + show_explicit_content?: boolean | null + updated_at?: string | null + usage_reason?: string | null + } + Relationships: [ + { + foreignKeyName: "profiles_couple_id_fkey" + columns: ["couple_id"] + isOneToOne: false + referencedRelation: "couples" + referencedColumns: ["id"] + }, + ] + } + question_packs: { + Row: { + avg_intensity: number | null + category_id: string | null + created_at: string | null + description: string | null + icon: string | null + id: string + 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 + icon?: string | null + id?: string + 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 + icon?: string | null + id?: string + 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 + } + Relationships: [ + { + foreignKeyName: "question_packs_category_id_fkey" + columns: ["category_id"] + isOneToOne: false + referencedRelation: "categories" + referencedColumns: ["id"] + }, + ] + } + questions: { + Row: { + allowed_couple_genders: string[] | null + created_at: string | null + id: string + intensity: number | null + pack_id: string + partner_text: string | null + target_user_genders: string[] | null + text: string + } + Insert: { + allowed_couple_genders?: string[] | null + created_at?: string | null + id?: string + intensity?: number | null + pack_id: string + partner_text?: string | null + target_user_genders?: string[] | null + text: string + } + Update: { + allowed_couple_genders?: string[] | null + created_at?: string | null + id?: string + intensity?: number | null + pack_id?: string + partner_text?: string | null + target_user_genders?: string[] | null + text?: string + } + Relationships: [ + { + foreignKeyName: "questions_pack_id_fkey" + columns: ["pack_id"] + isOneToOne: false + referencedRelation: "question_packs" + referencedColumns: ["id"] + }, + ] + } + redemption_codes: { + Row: { + code: string + created_at: string | null + created_by: string | null + current_uses: number | null + description: string | null + expires_at: string | null + id: string + is_active: boolean | null + max_uses: number | null + updated_at: string | null + } + Insert: { + code: string + created_at?: string | null + created_by?: string | null + current_uses?: number | null + description?: string | null + expires_at?: string | null + id?: string + is_active?: boolean | null + max_uses?: number | null + updated_at?: string | null + } + Update: { + code?: string + created_at?: string | null + created_by?: string | null + current_uses?: number | null + description?: string | null + expires_at?: string | null + id?: string + is_active?: boolean | null + max_uses?: number | null + updated_at?: string | null + } + Relationships: [] + } + responses: { + Row: { + answer: Database["public"]["Enums"]["answer_type"] + couple_id: string + created_at: string | null + id: string + question_id: string + user_id: string + } + Insert: { + answer: Database["public"]["Enums"]["answer_type"] + couple_id: string + created_at?: string | null + id?: string + question_id: string + user_id: string + } + Update: { + answer?: Database["public"]["Enums"]["answer_type"] + couple_id?: string + created_at?: string | null + id?: string + question_id?: string + user_id?: string + } + Relationships: [ + { + foreignKeyName: "responses_couple_id_fkey" + columns: ["couple_id"] + isOneToOne: false + referencedRelation: "couples" + referencedColumns: ["id"] + }, + { + foreignKeyName: "responses_question_id_fkey" + columns: ["question_id"] + isOneToOne: false + referencedRelation: "questions" + referencedColumns: ["id"] + }, + { + foreignKeyName: "responses_user_id_fkey" + columns: ["user_id"] + isOneToOne: false + referencedRelation: "profiles" + referencedColumns: ["id"] + }, + ] + } + revenuecat_webhook_events: { + Row: { + app_user_id: string + event_id: string + event_type: string + id: string + payload: Json | null + processed_at: string | null + } + Insert: { + app_user_id: string + event_id: string + event_type: string + id?: string + payload?: Json | null + processed_at?: string | null + } + Update: { + app_user_id?: string + event_id?: string + event_type?: string + id?: string + payload?: Json | null + processed_at?: string | null + } + 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 + created_at: string | null + entitlement_ids: string[] | null + expires_at: string | null + grace_period_expires_at: string | null + id: string + is_sandbox: boolean | null + original_transaction_id: string | null + product_id: string + purchased_at: string + revenuecat_app_user_id: string + status: Database["public"]["Enums"]["subscription_status"] + store: string | null + updated_at: string | null + user_id: string + } + Insert: { + cancel_reason?: string | null + created_at?: string | null + entitlement_ids?: string[] | null + expires_at?: string | null + grace_period_expires_at?: string | null + id?: string + is_sandbox?: boolean | null + original_transaction_id?: string | null + product_id: string + purchased_at: string + revenuecat_app_user_id: string + status?: Database["public"]["Enums"]["subscription_status"] + store?: string | null + updated_at?: string | null + user_id: string + } + Update: { + cancel_reason?: string | null + created_at?: string | null + entitlement_ids?: string[] | null + expires_at?: string | null + grace_period_expires_at?: string | null + id?: string + is_sandbox?: boolean | null + original_transaction_id?: string | null + product_id?: string + purchased_at?: string + revenuecat_app_user_id?: string + status?: Database["public"]["Enums"]["subscription_status"] + store?: string | null + updated_at?: string | null + user_id?: string + } + Relationships: [ + { + foreignKeyName: "subscriptions_user_id_fkey" + columns: ["user_id"] + isOneToOne: false + referencedRelation: "profiles" + referencedColumns: ["id"] + }, + ] + } + topics: { + Row: { + created_at: string | null + description: string | null + icon: string | null + id: string + name: string + sort_order: number | null + } + Insert: { + created_at?: string | null + description?: string | null + icon?: string | null + id?: string + name: string + sort_order?: number | null + } + Update: { + created_at?: string | null + description?: string | null + icon?: string | null + id?: string + name?: string + sort_order?: number | null + } + Relationships: [] + } + } + Views: { + [_ in never]: never + } + Functions: { + cleanup_expired_videos: { + Args: never + Returns: { + deleted_count: number + deleted_paths: string[] + }[] + } + get_admin_role: { + Args: { check_user_id?: string } + Returns: Database["public"]["Enums"]["admin_role"] + } + get_answer_gap_status: { + Args: never + Returns: { + is_blocked: boolean + threshold: number + unanswered_by_partner: number + }[] + } + 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: { + is_blocked: boolean + limit_value: number + remaining: number + reset_at: string + responses_today: number + }[] + } + get_feature_interest_counts: { + Args: never + Returns: { + feature_name: string + opt_in_count: number + opt_in_count_last_7_days: number + }[] + } + get_pack_teaser_questions: { + Args: { target_pack_id: string } + Returns: { + id: string + intensity: number + text: string + }[] + } + get_profiles_with_auth_info: { + Args: never + Returns: { + avatar_url: string + couple_id: string + created_at: string + email: string + email_confirmed_at: string + id: string + is_premium: boolean + last_sign_in_at: string + name: string + }[] + } + get_recommended_questions: { + Args: { target_pack_id?: string } + Returns: { + allowed_couple_genders: string[] + id: string + intensity: number + is_two_part: boolean + pack_id: string + partner_answered: boolean + partner_text: string + target_user_genders: string[] + 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: { + bucket_id: string + created_at: string + id: string + metadata: Json + name: string + }[] + } + get_user_storage_usage: { + Args: never + Returns: { + owner: string + total_bytes: number + }[] + } + has_permission: { Args: { check_permission: string }; Returns: boolean } + has_premium_access: { Args: { check_user_id: string }; Returns: boolean } + is_admin: { Args: { check_user_id?: string }; Returns: boolean } + is_super_admin: { Args: { check_user_id?: string }; Returns: boolean } + log_admin_action: { + Args: { + p_action: Database["public"]["Enums"]["audit_action"] + p_new_values?: Json + p_old_values?: Json + p_record_id: string + p_table_name: string + } + Returns: string + } + redeem_code_by_email: { + 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" + | "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" + | "expired" + | "billing_issue" + | "paused" + } + CompositeTypes: { + [_ in never]: never + } + } +} + +type DatabaseWithoutInternals = Omit + +type DefaultSchema = DatabaseWithoutInternals[Extract] + +export type Tables< + DefaultSchemaTableNameOrOptions extends + | keyof (DefaultSchema["Tables"] & DefaultSchema["Views"]) + | { schema: keyof DatabaseWithoutInternals }, + TableName extends DefaultSchemaTableNameOrOptions extends { + schema: keyof DatabaseWithoutInternals + } + ? keyof (DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Tables"] & + DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Views"]) + : never = never, +> = DefaultSchemaTableNameOrOptions extends { + schema: keyof DatabaseWithoutInternals +} + ? (DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Tables"] & + DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Views"])[TableName] extends { + Row: infer R + } + ? R + : never + : DefaultSchemaTableNameOrOptions extends keyof (DefaultSchema["Tables"] & + DefaultSchema["Views"]) + ? (DefaultSchema["Tables"] & + DefaultSchema["Views"])[DefaultSchemaTableNameOrOptions] extends { + Row: infer R + } + ? R + : never + : never + +export type TablesInsert< + DefaultSchemaTableNameOrOptions extends + | keyof DefaultSchema["Tables"] + | { schema: keyof DatabaseWithoutInternals }, + TableName extends DefaultSchemaTableNameOrOptions extends { + schema: keyof DatabaseWithoutInternals + } + ? keyof DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Tables"] + : never = never, +> = DefaultSchemaTableNameOrOptions extends { + schema: keyof DatabaseWithoutInternals +} + ? DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Tables"][TableName] extends { + Insert: infer I + } + ? I + : never + : DefaultSchemaTableNameOrOptions extends keyof DefaultSchema["Tables"] + ? DefaultSchema["Tables"][DefaultSchemaTableNameOrOptions] extends { + Insert: infer I + } + ? I + : never + : never + +export type TablesUpdate< + DefaultSchemaTableNameOrOptions extends + | keyof DefaultSchema["Tables"] + | { schema: keyof DatabaseWithoutInternals }, + TableName extends DefaultSchemaTableNameOrOptions extends { + schema: keyof DatabaseWithoutInternals + } + ? keyof DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Tables"] + : never = never, +> = DefaultSchemaTableNameOrOptions extends { + schema: keyof DatabaseWithoutInternals +} + ? DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Tables"][TableName] extends { + Update: infer U + } + ? U + : never + : DefaultSchemaTableNameOrOptions extends keyof DefaultSchema["Tables"] + ? DefaultSchema["Tables"][DefaultSchemaTableNameOrOptions] extends { + Update: infer U + } + ? U + : never + : never + +export type Enums< + DefaultSchemaEnumNameOrOptions extends + | keyof DefaultSchema["Enums"] + | { schema: keyof DatabaseWithoutInternals }, + EnumName extends DefaultSchemaEnumNameOrOptions extends { + schema: keyof DatabaseWithoutInternals + } + ? keyof DatabaseWithoutInternals[DefaultSchemaEnumNameOrOptions["schema"]]["Enums"] + : never = never, +> = DefaultSchemaEnumNameOrOptions extends { + schema: keyof DatabaseWithoutInternals +} + ? DatabaseWithoutInternals[DefaultSchemaEnumNameOrOptions["schema"]]["Enums"][EnumName] + : DefaultSchemaEnumNameOrOptions extends keyof DefaultSchema["Enums"] + ? DefaultSchema["Enums"][DefaultSchemaEnumNameOrOptions] + : never + +export type CompositeTypes< + PublicCompositeTypeNameOrOptions extends + | keyof DefaultSchema["CompositeTypes"] + | { schema: keyof DatabaseWithoutInternals }, + CompositeTypeName extends PublicCompositeTypeNameOrOptions extends { + schema: keyof DatabaseWithoutInternals + } + ? keyof DatabaseWithoutInternals[PublicCompositeTypeNameOrOptions["schema"]]["CompositeTypes"] + : never = never, +> = PublicCompositeTypeNameOrOptions extends { + schema: keyof DatabaseWithoutInternals +} + ? DatabaseWithoutInternals[PublicCompositeTypeNameOrOptions["schema"]]["CompositeTypes"][CompositeTypeName] + : PublicCompositeTypeNameOrOptions extends keyof DefaultSchema["CompositeTypes"] + ? DefaultSchema["CompositeTypes"][PublicCompositeTypeNameOrOptions] + : never + +export const Constants = { + public: { + 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", "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", + "expired", + "billing_issue", + "paused", + ], + }, + }, +} as const