diff --git a/.mcp.json b/.mcp.json index a0325bf..9a9387c 100644 --- a/.mcp.json +++ b/.mcp.json @@ -1,8 +1,12 @@ { "mcpServers": { - "supabase": { - "type": "http", - "url": "https://mcp.supabase.com/mcp?project_ref=npmmakhjmbzlkcmfyegw" - } + "sauci-prod": { + "type": "http", + "url": "https://mcp.supabase.com/mcp?project_ref=ckjcrkjpmhqhiucifukx" + }, + "sauci-non-prod": { + "type": "http", + "url": "https://mcp.supabase.com/mcp?project_ref=itbzhrvlgvdmzbnhzhyx" + } } } \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md index 7b11390..6a8ccd1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -45,6 +45,20 @@ supabase functions serve # Run edge functions locally supabase migration new # Create new migration ``` +### Supabase Environments + +There are two Supabase projects configured via MCP servers: + +| Environment | Project Ref | URL | MCP Server | +|-------------|-------------|-----|------------| +| **Production** | `ckjcrkjpmhqhiucifukx` | https://ckjcrkjpmhqhiucifukx.supabase.co | `sauci-prod` | +| **Non-Production** | `itbzhrvlgvdmzbnhzhyx` | https://itbzhrvlgvdmzbnhzhyx.supabase.co | `sauci-non-prod` | + +When using Claude Code with MCP tools: +- Use `mcp__sauci-prod__*` tools for production database operations +- Use `mcp__sauci-non-prod__*` tools for development/staging database operations +- **Always verify which environment you're targeting before running migrations or destructive operations** + ## Architecture ### Monorepo Structure (Turborepo) diff --git a/apps/admin/.env.example b/apps/admin/.env.example index a66a7b9..dcaaa06 100644 --- a/apps/admin/.env.example +++ b/apps/admin/.env.example @@ -2,4 +2,12 @@ VITE_SUPABASE_URL=your_supabase_url VITE_SUPABASE_ANON_KEY=your_supabase_anon_key VITE_OPENAI_API_KEY=your_openai_api_key VITE_OPENROUTER_API_KEY=your_openrouter_api_key + +# AI Model Configuration +# VITE_AI_MODEL is the fallback for all operations VITE_AI_MODEL=openai/gpt-4o-mini + +# Granular model overrides (optional - each falls back to VITE_AI_MODEL) +# VITE_AI_MODEL_GENERATE - for pack/question/category generation +# VITE_AI_MODEL_FIX - for fix targets and fix text analysis +# VITE_AI_MODEL_POLISH - for polishing/tidying up text diff --git a/apps/admin/docker-compose.yml b/apps/admin/docker-compose.yml index 1b7587b..3b498d1 100644 --- a/apps/admin/docker-compose.yml +++ b/apps/admin/docker-compose.yml @@ -11,9 +11,13 @@ services: - VITE_OPENAI_API_KEY=${VITE_OPENAI_API_KEY} - VITE_OPENROUTER_API_KEY=${VITE_OPENROUTER_API_KEY} - VITE_AI_MODEL=${VITE_AI_MODEL} + - VITE_AI_MODEL_GENERATE=${VITE_AI_MODEL_GENERATE} + - VITE_AI_MODEL_FIX=${VITE_AI_MODEL_FIX} + - VITE_AI_MODEL_POLISH=${VITE_AI_MODEL_POLISH} volumes: - ./src:/app/apps/admin/src - ./index.html:/app/apps/admin/index.html - ./vite.config.ts:/app/apps/admin/vite.config.ts - ./tsconfig.json:/app/apps/admin/tsconfig.json + - ../../packages/shared/src:/app/packages/shared/src restart: unless-stopped diff --git a/apps/admin/src/components/ai/AiGeneratorDialog.tsx b/apps/admin/src/components/ai/AiGeneratorDialog.tsx index c1bf874..e64d3a1 100644 --- a/apps/admin/src/components/ai/AiGeneratorDialog.tsx +++ b/apps/admin/src/components/ai/AiGeneratorDialog.tsx @@ -1,7 +1,8 @@ -import { useState } from 'react'; +import { useState, useEffect } from 'react'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; +import { Checkbox } from '@/components/ui/checkbox'; import { Dialog, DialogContent, @@ -10,8 +11,8 @@ import { DialogHeader, DialogTitle, } from '@/components/ui/dialog'; -import { Sparkles, Loader2, RefreshCw, Check } from 'lucide-react'; -import { generatePack, generateQuestions, suggestCategories, suggestPacks, type GeneratedPack, type GeneratedQuestion, type GeneratedCategoryIdea, type GeneratedPackIdea } from '@/lib/openai'; +import { Sparkles, Loader2, RefreshCw, Check, CheckSquare, Square } from 'lucide-react'; +import { generatePack, generateQuestions, suggestCategories, suggestPacks, TONE_LEVELS, INTENSITY_LEVELS, type GeneratedPack, type GeneratedQuestion, type GeneratedCategoryIdea, type GeneratedPackIdea, type ToneLevel } from '@/lib/openai'; import { Switch } from '@/components/ui/switch'; import { toast } from 'sonner'; @@ -22,8 +23,10 @@ interface AiGeneratorDialogProps { context?: { categoryName?: string; packName?: string; + packDescription?: string | null; existingCategories?: string[]; existingPacks?: string[]; + isExplicit?: boolean; }; onGenerated: (result: any) => void; } @@ -37,7 +40,16 @@ export function AiGeneratorDialog({ }: AiGeneratorDialogProps) { const [loading, setLoading] = useState(false); const [count, setCount] = useState(10); - const [isExplicit, setIsExplicit] = useState(false); + // Default tone based on pack's explicit flag (5 for explicit packs, 3 for non-explicit) + const [tone, setTone] = useState(context?.isExplicit ? 5 : 3); + const isExplicit = tone >= 4; // For other generators that still use boolean + + // Update tone when dialog opens with new context + useEffect(() => { + if (open && type === 'questions') { + setTone(context?.isExplicit ? 5 : 3); + } + }, [open, context?.isExplicit, type]); // Generated results const [generatedPack, setGeneratedPack] = useState(null); @@ -45,6 +57,9 @@ export function AiGeneratorDialog({ const [generatedIdeas, setGeneratedIdeas] = useState([]); const [generatedPackIdeas, setGeneratedPackIdeas] = useState([]); + // Selection state for questions + const [selectedQuestionIndices, setSelectedQuestionIndices] = useState>(new Set()); + const handleGenerate = async () => { setLoading(true); try { @@ -56,8 +71,10 @@ export function AiGeneratorDialog({ toast.error('Pack name is required'); return; } - const result = await generateQuestions(context.packName, count, undefined, isExplicit); + const result = await generateQuestions(context.packName, count, undefined, tone, context.packDescription || undefined); setGeneratedQuestions(result); + // Select all questions by default + setSelectedQuestionIndices(new Set(result.map((_, i) => i))); } else if (type === 'category-ideas') { const result = await suggestCategories(context?.existingCategories || [], isExplicit); setGeneratedIdeas(result); @@ -81,16 +98,43 @@ export function AiGeneratorDialog({ if (type === 'pack' && generatedPack) { onGenerated(generatedPack); } else if (type === 'questions' && generatedQuestions.length > 0) { - onGenerated(generatedQuestions); + // Only pass selected questions + const selectedQuestions = generatedQuestions.filter((_, i) => selectedQuestionIndices.has(i)); + if (selectedQuestions.length === 0) { + toast.error('Please select at least one question'); + return; + } + onGenerated(selectedQuestions); } }; + const toggleQuestionSelection = (index: number) => { + setSelectedQuestionIndices(prev => { + const newSet = new Set(prev); + if (newSet.has(index)) { + newSet.delete(index); + } else { + newSet.add(index); + } + return newSet; + }); + }; + + const selectAllQuestions = () => { + setSelectedQuestionIndices(new Set(generatedQuestions.map((_, i) => i))); + }; + + const deselectAllQuestions = () => { + setSelectedQuestionIndices(new Set()); + }; + const handleClose = () => { // Reset state setGeneratedPack(null); setGeneratedQuestions([]); setGeneratedIdeas([]); setGeneratedPackIdeas([]); + setSelectedQuestionIndices(new Set()); onOpenChange(false); }; @@ -130,13 +174,38 @@ export function AiGeneratorDialog({ )} - {/* Explicit Toggle */} - {(type === 'category-ideas' || type === 'category-pack-ideas' || type === 'pack' || type === 'questions') && ( + {/* Tone Selector for Questions */} + {type === 'questions' && ( +
+ +
+ {TONE_LEVELS.map((t) => ( + + ))} +
+

+ {TONE_LEVELS.find(t => t.level === tone)?.description} +

+
+ )} + + {/* Explicit Toggle for other generators */} + {(type === 'category-ideas' || type === 'category-pack-ideas' || type === 'pack') && (
setTone(checked ? 5 : 3)} />
@@ -149,8 +218,11 @@ export function AiGeneratorDialog({ )} {context?.packName && ( -
- Pack: {context.packName} +
+
Pack: {context.packName}
+ {context.packDescription && ( +
{context.packDescription}
+ )}
)} @@ -185,19 +257,66 @@ export function AiGeneratorDialog({ {/* Generated Questions Preview */} {generatedQuestions.length > 0 && (
-

Generated Questions ({generatedQuestions.length})

+
+

+ Generated Questions ({selectedQuestionIndices.size}/{generatedQuestions.length} selected) +

+
+ + +
+
{generatedQuestions.map((q, i) => ( -
-

{q.text}

- {q.partner_text && ( -

- Partner: {q.partner_text} -

- )} - - {q.intensity} - {['Light', 'Mild', 'Moderate', 'Spicy', 'Very Intense'][q.intensity - 1]} - +
toggleQuestionSelection(i)} + > +
+ toggleQuestionSelection(i)} + onClick={(e) => e.stopPropagation()} + className="mt-0.5" + /> +
+

{q.text}

+ {q.partner_text && ( +

+ Partner: {q.partner_text} +

+ )} + + {q.intensity} - {INTENSITY_LEVELS[q.intensity - 1]?.label || 'Unknown'} + +
+
))}
@@ -217,7 +336,10 @@ export function AiGeneratorDialog({ >
{idea.icon} - {idea.name} +
+ {idea.name} + {idea.description} +
@@ -262,9 +384,9 @@ export function AiGeneratorDialog({ Regenerate {type !== 'category-ideas' && type !== 'category-pack-ideas' && ( - )} diff --git a/apps/admin/src/components/content/FixTargetsDialog.tsx b/apps/admin/src/components/content/FixTargetsDialog.tsx index dd8df85..f4fa9ff 100644 --- a/apps/admin/src/components/content/FixTargetsDialog.tsx +++ b/apps/admin/src/components/content/FixTargetsDialog.tsx @@ -12,7 +12,7 @@ import { Badge } from '@/components/ui/badge'; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'; import { Loader2, ShieldCheck, ArrowRight, Check } from 'lucide-react'; import { analyzeQuestionTargets, TargetAnalysis } from '@/lib/openai'; -import { supabase } from '@/config'; +import { auditedSupabase } from '@/hooks/useAuditedSupabase'; import { toast } from 'sonner'; // Define local compatible interface @@ -22,6 +22,7 @@ interface Question { partner_text: string | null; intensity: number; allowed_couple_genders: string[] | null; + target_user_genders: string[] | null; } interface FixTargetsDialogProps { @@ -38,13 +39,58 @@ export function FixTargetsDialog({ open, onOpenChange, questions, onUpdated }: F const [step, setStep] = useState<'initial' | 'review'>('initial'); const [applying, setApplying] = useState(false); + // Check if couple targets represent "All" (null, empty, or all three values) + const isAllCoupleTargets = (targets: string[] | null): boolean => { + if (!targets || targets.length === 0) return true; + if (targets.length === 3) { + const sorted = [...targets].sort(); + return sorted[0] === 'female+female' && sorted[1] === 'female+male' && sorted[2] === 'male+male'; + } + return false; + }; + + // Check if initiator targets represent "All" (null or empty) + const isAllInitiator = (targets: string[] | null): boolean => { + return !targets || targets.length === 0; + }; + + // Check if two arrays are equal + const arraysEqual = (a: string[] | null, b: string[] | null): boolean => { + if (!a && !b) return true; + if (!a || !b) return false; + if (a.length !== b.length) return false; + const sortedA = [...a].sort(); + const sortedB = [...b].sort(); + return sortedA.every((val, i) => val === sortedB[i]); + }; + const handleAnalyze = async () => { setAnalyzing(true); try { const results = await analyzeQuestionTargets(questions); - setSuggestions(results); + // Filter out redundant suggestions where nothing meaningful changes + const meaningfulResults = results.filter(suggestion => { + const question = questions.find(q => q.id === suggestion.id); + if (!question) return false; + + // Check if couple targets change meaningfully + const currentCoupleIsAll = isAllCoupleTargets(question.allowed_couple_genders); + const suggestedCoupleIsAll = isAllCoupleTargets(suggestion.suggested_targets); + const coupleChanged = !(currentCoupleIsAll && suggestedCoupleIsAll) && + !arraysEqual(question.allowed_couple_genders, suggestion.suggested_targets); + + // Check if initiator changes meaningfully + const currentInitiatorIsAll = isAllInitiator(question.target_user_genders); + const suggestedInitiatorIsAll = isAllInitiator(suggestion.suggested_initiator); + const initiatorChanged = !(currentInitiatorIsAll && suggestedInitiatorIsAll) && + !arraysEqual(question.target_user_genders, suggestion.suggested_initiator); + + // Include if either changed + return coupleChanged || initiatorChanged; + }); + setSuggestions(meaningfulResults); // Default select all - setSelectedIds(new Set(results.map(r => r.id))); + setSelectedIds(new Set(meaningfulResults.map(r => r.id))); setStep('review'); } catch (error) { console.error(error); @@ -63,12 +109,12 @@ export function FixTargetsDialog({ open, onOpenChange, questions, onUpdated }: F return; } - // Apply updates in parallel + // Apply updates in parallel with audit logging await Promise.all(updates.map(update => - supabase - .from('questions') - .update({ allowed_couple_genders: update.suggested_targets && update.suggested_targets.length > 0 ? update.suggested_targets : null }) - .eq('id', update.id) + auditedSupabase.update('questions', update.id, { + allowed_couple_genders: update.suggested_targets && update.suggested_targets.length > 0 ? update.suggested_targets : null, + target_user_genders: update.suggested_initiator && update.suggested_initiator.length > 0 ? update.suggested_initiator : null, + }) )); toast.success(`Updated ${updates.length} questions`); @@ -103,6 +149,11 @@ export function FixTargetsDialog({ open, onOpenChange, questions, onUpdated }: F return targets.map(t => t.replace('male+male', 'M+M').replace('female+male', 'M+F').replace('female+female', 'F+F')).join(', '); }; + const formatInitiator = (targets: string[] | null) => { + if (!targets || targets.length === 0) return 'Any'; + return targets.map(t => t.charAt(0).toUpperCase() + t.slice(1)).join(', '); + }; + return ( @@ -163,8 +214,8 @@ export function FixTargetsDialog({ open, onOpenChange, questions, onUpdated }: F /> Question - Current - Suggested + Couples + Initiator Reason @@ -190,19 +241,28 @@ export function FixTargetsDialog({ open, onOpenChange, questions, onUpdated }: F
)} - - - {formatTargets(question.allowed_couple_genders)} - - - +
+ + {formatTargets(question.allowed_couple_genders)} + {formatTargets(suggestion.suggested_targets)}
+ +
+ + {formatInitiator(question.target_user_genders)} + + + + {formatInitiator(suggestion.suggested_initiator)} + +
+
{suggestion.reason} diff --git a/apps/admin/src/components/layout/Breadcrumbs.tsx b/apps/admin/src/components/layout/Breadcrumbs.tsx index ff451af..2d2dcaf 100644 --- a/apps/admin/src/components/layout/Breadcrumbs.tsx +++ b/apps/admin/src/components/layout/Breadcrumbs.tsx @@ -1,15 +1,55 @@ +import { useState, useEffect } from 'react'; import { Link, useLocation, useParams } from 'react-router-dom'; import { ChevronRight, Home } from 'lucide-react'; +import { supabase } from '@/config'; interface BreadcrumbItem { label: string; href?: string; } +interface PackInfo { + category_id: string | null; + category_name: string | null; +} + // Generate breadcrumbs based on current path and params -function useBreadcrumbs(): BreadcrumbItem[] { +function useBreadcrumbs(): { breadcrumbs: BreadcrumbItem[]; loading: boolean } { const location = useLocation(); const params = useParams(); + const [packInfo, setPackInfo] = useState(null); + const [loading, setLoading] = useState(false); + + // Fetch pack info when on questions page to get category_id + useEffect(() => { + const fetchPackInfo = async () => { + if (params.packId && !params.categoryId) { + setLoading(true); + try { + const { data } = await supabase + .from('question_packs') + .select('category_id, categories(name)') + .eq('id', params.packId) + .single(); + + if (data) { + setPackInfo({ + category_id: data.category_id, + category_name: (data.categories as any)?.name || null, + }); + } + } catch (error) { + console.error('Failed to fetch pack info for breadcrumbs:', error); + } finally { + setLoading(false); + } + } else { + setPackInfo(null); + } + }; + + fetchPackInfo(); + }, [params.packId, params.categoryId]); const pathSegments = location.pathname.split('/').filter(Boolean); const breadcrumbs: BreadcrumbItem[] = []; @@ -23,6 +63,13 @@ function useBreadcrumbs(): BreadcrumbItem[] { breadcrumbs.push({ label: 'Categories', href: '/categories' }); } else if (segment === 'packs' && params.categoryId) { breadcrumbs.push({ label: 'Packs', href: currentPath }); + } else if (segment === 'packs' && params.packId && packInfo?.category_id) { + // On questions page, link back to category's packs + breadcrumbs.push({ label: 'Categories', href: '/categories' }); + breadcrumbs.push({ + label: packInfo.category_name || 'Packs', + href: `/categories/${packInfo.category_id}/packs` + }); } else if (segment === 'questions' && params.packId) { breadcrumbs.push({ label: 'Questions', href: currentPath }); } else if (segment === 'users') { @@ -44,13 +91,13 @@ function useBreadcrumbs(): BreadcrumbItem[] { breadcrumbs[breadcrumbs.length - 1].href = undefined; } - return breadcrumbs; + return { breadcrumbs, loading }; } export function Breadcrumbs() { - const breadcrumbs = useBreadcrumbs(); + const { breadcrumbs, loading } = useBreadcrumbs(); - if (breadcrumbs.length === 0) return null; + if (breadcrumbs.length === 0 && !loading) return null; return (
+ +
+
+ +

+ Contains adult or mature content +

+
+ + setFormData(d => ({ ...d, is_explicit: checked })) + } + /> +
@@ -429,10 +443,26 @@ export function PacksPage() { Premium )} - {!pack.is_public && ( + {pack.is_public ? ( + + + Published + + ) : ( - Hidden + Draft + + )} + {pack.is_explicit ? ( + + + Explicit + + ) : ( + + + Safe )} diff --git a/apps/admin/src/pages/content/QuestionsPage.tsx b/apps/admin/src/pages/content/QuestionsPage.tsx index 709f932..cca2258 100644 --- a/apps/admin/src/pages/content/QuestionsPage.tsx +++ b/apps/admin/src/pages/content/QuestionsPage.tsx @@ -1,8 +1,8 @@ import { useState, useEffect } from 'react'; import { useParams } from 'react-router-dom'; import { supabase } from '@/config'; +import { auditedSupabase } from '@/hooks/useAuditedSupabase'; import { Button } from '@/components/ui/button'; -import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; import { Textarea } from '@/components/ui/textarea'; import { Badge } from '@/components/ui/badge'; @@ -25,16 +25,18 @@ import { DialogTrigger, } from '@/components/ui/dialog'; import { Card } from '@/components/ui/card'; -import { Plus, Pencil, Trash2, Sparkles, Loader2, Users, ShieldCheck } from 'lucide-react'; +import { Plus, Pencil, Trash2, Sparkles, Loader2, Users } from 'lucide-react'; import { toast } from 'sonner'; import { AiGeneratorDialog } from '@/components/ai/AiGeneratorDialog'; import { AIPolishButton } from '@/components/ai/AIPolishButton'; -import { FixTargetsDialog } from '@/components/content/FixTargetsDialog'; +import { ReviewQuestionsDialog } from '@/components/content/ReviewQuestionsDialog'; interface QuestionPack { id: string; name: string; + description: string | null; icon: string | null; + is_explicit: boolean; } interface Question { @@ -44,11 +46,14 @@ interface Question { partner_text: string | null; intensity: number; allowed_couple_genders: string[] | null; + target_user_genders: string[] | null; created_at: string | null; } -const intensityLabels = ['', 'Light', 'Mild', 'Medium', 'Spicy', 'Intense']; -const intensityColors = ['', 'bg-green-500', 'bg-lime-500', 'bg-yellow-500', 'bg-orange-500', 'bg-red-500']; +import { INTENSITY_LEVELS } from '@/lib/openai'; + +// Helper to get intensity info by level (1-indexed) +const getIntensity = (level: number) => INTENSITY_LEVELS[level - 1] || INTENSITY_LEVELS[0]; export function QuestionsPage() { const { packId } = useParams<{ packId: string }>(); @@ -57,7 +62,7 @@ export function QuestionsPage() { const [loading, setLoading] = useState(true); const [dialogOpen, setDialogOpen] = useState(false); const [aiDialogOpen, setAiDialogOpen] = useState(false); - const [fixTargetsOpen, setFixTargetsOpen] = useState(false); + const [reviewOpen, setReviewOpen] = useState(false); const [editingQuestion, setEditingQuestion] = useState(null); const [saving, setSaving] = useState(false); @@ -67,11 +72,13 @@ export function QuestionsPage() { partner_text: string; intensity: number; allowed_couple_genders: string[]; + target_user_genders: string[]; }>({ text: '', partner_text: '', intensity: 1, allowed_couple_genders: [], + target_user_genders: [], }); const fetchData = async () => { @@ -82,7 +89,7 @@ export function QuestionsPage() { // Fetch pack info const { data: packData } = await supabase .from('question_packs') - .select('id, name, icon') + .select('id, name, description, icon, is_explicit') .eq('id', packId) .single(); @@ -111,7 +118,7 @@ export function QuestionsPage() { const openCreateDialog = () => { setEditingQuestion(null); - setFormData({ text: '', partner_text: '', intensity: 1, allowed_couple_genders: [] }); + setFormData({ text: '', partner_text: '', intensity: 1, allowed_couple_genders: [], target_user_genders: [] }); setDialogOpen(true); }; @@ -122,6 +129,7 @@ export function QuestionsPage() { partner_text: question.partner_text || '', intensity: question.intensity, allowed_couple_genders: question.allowed_couple_genders || [], + target_user_genders: question.target_user_genders || [], }); setDialogOpen(true); }; @@ -135,28 +143,25 @@ export function QuestionsPage() { setSaving(true); try { if (editingQuestion) { - const { error } = await supabase - .from('questions') - .update({ - text: formData.text, - partner_text: formData.partner_text || null, - intensity: formData.intensity, - allowed_couple_genders: formData.allowed_couple_genders.length > 0 ? formData.allowed_couple_genders : null, - }) - .eq('id', editingQuestion.id); + const { error } = await auditedSupabase.update('questions', editingQuestion.id, { + text: formData.text, + partner_text: formData.partner_text || null, + 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, + }); if (error) throw error; toast.success('Question updated'); } else { - const { error } = await supabase - .from('questions') - .insert({ - pack_id: packId, - text: formData.text, - partner_text: formData.partner_text || null, - intensity: formData.intensity, - allowed_couple_genders: formData.allowed_couple_genders.length > 0 ? formData.allowed_couple_genders : null, - }); + const { error } = await auditedSupabase.insert('questions', { + pack_id: packId, + text: formData.text, + partner_text: formData.partner_text || null, + 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, + }); if (error) throw error; toast.success('Question created'); @@ -178,10 +183,7 @@ export function QuestionsPage() { } try { - const { error } = await supabase - .from('questions') - .delete() - .eq('id', question.id); + const { error } = await auditedSupabase.delete('questions', question.id); if (error) throw error; toast.success('Question deleted'); @@ -196,16 +198,15 @@ export function QuestionsPage() { // Bulk insert AI-generated questions const insertQuestions = async () => { try { - const { error } = await supabase - .from('questions') - .insert( - generatedQuestions.map(q => ({ - pack_id: packId, - text: q.text, - partner_text: q.partner_text || null, - intensity: q.intensity, - })) - ); + const { error } = await auditedSupabase.insert( + 'questions', + generatedQuestions.map(q => ({ + pack_id: packId, + text: q.text, + partner_text: q.partner_text || null, + intensity: q.intensity, + })) + ); if (error) throw error; toast.success(`${generatedQuestions.length} questions added`); @@ -250,9 +251,9 @@ export function QuestionsPage() {

-
@@ -308,6 +310,7 @@ export function QuestionsPage() { setFormData(d => ({ ...d, partner_text: val }))} /> @@ -326,16 +329,17 @@ export function QuestionsPage() {
- {[1, 2, 3, 4, 5].map((level) => ( + {INTENSITY_LEVELS.map((intensity) => ( ))}
@@ -375,6 +379,40 @@ export function QuestionsPage() { Select specific couple types to restrict this question. Leave all unselected to allow for everyone.

+ +
+ +
+ {[ + { id: 'male', label: 'Male' }, + { id: 'female', label: 'Female' }, + { id: 'non-binary', label: 'Non-binary' }, + ].map((type) => { + const isSelected = formData.target_user_genders.includes(type.id); + return ( + + ); + })} +
+

+ Who sees this question first? Their partner will see the Partner Text after they answer. Leave empty for anyone. +

+
@@ -402,14 +440,15 @@ export function QuestionsPage() { open={aiDialogOpen} onOpenChange={setAiDialogOpen} type="questions" - context={{ packName: pack?.name }} + context={{ packName: pack?.name, packDescription: pack?.description, isExplicit: pack?.is_explicit }} onGenerated={handleAiGenerated} /> - @@ -441,7 +480,8 @@ export function QuestionsPage() { # Question Partner Text - Targets + Couples + Initiator Intensity Actions @@ -479,8 +519,24 @@ export function QuestionsPage() { )} - - {intensityLabels[question.intensity]} + {question.target_user_genders && question.target_user_genders.length > 0 ? ( +
+ {question.target_user_genders.map(g => ( + + {g.charAt(0).toUpperCase()} + + ))} +
+ ) : ( + Any + )} +
+ + + {getIntensity(question.intensity).label} diff --git a/apps/admin/src/pages/users/UserDetailPage.tsx b/apps/admin/src/pages/users/UserDetailPage.tsx index 73cda14..d453309 100644 --- a/apps/admin/src/pages/users/UserDetailPage.tsx +++ b/apps/admin/src/pages/users/UserDetailPage.tsx @@ -1,6 +1,7 @@ import { useState, useEffect } from 'react'; import { useParams, Link } from 'react-router-dom'; import { supabase } from '@/config'; +import { auditedSupabase } from '@/hooks/useAuditedSupabase'; import { Button } from '@/components/ui/button'; import { Badge } from '@/components/ui/badge'; import { Skeleton } from '@/components/ui/skeleton'; @@ -128,17 +129,15 @@ export function UserDetailPage() { expiresAt = date.toISOString(); } - const { error } = await supabase - .from('subscriptions') - .insert({ - user_id: userId, - revenuecat_app_user_id: 'admin_grant', - product_id: 'admin_premium', - status: 'active', - store: 'manual', - purchased_at: new Date().toISOString(), - expires_at: expiresAt, - }); + const { error } = await auditedSupabase.insert('subscriptions', { + user_id: userId, + revenuecat_app_user_id: 'admin_grant', + product_id: 'admin_premium', + status: 'active', + store: 'manual', + purchased_at: new Date().toISOString(), + expires_at: expiresAt, + }); if (error) throw error; diff --git a/apps/admin/src/providers/supabaseProvider.ts b/apps/admin/src/providers/supabaseProvider.ts index 41982cc..efdd639 100755 --- a/apps/admin/src/providers/supabaseProvider.ts +++ b/apps/admin/src/providers/supabaseProvider.ts @@ -4,8 +4,9 @@ import { supabase } from '../config'; export { supabase }; const resources = { - question_packs: ['id', 'name', 'description', 'icon', 'is_premium', 'is_public', 'sort_order', 'created_at'], - questions: ['id', 'pack_id', 'text', 'partner_text', 'intensity', 'created_at'], + categories: ['id', 'name', 'description', 'icon', 'sort_order', 'created_at'], + question_packs: ['id', 'name', 'description', 'icon', 'is_premium', 'is_public', 'is_explicit', 'sort_order', 'created_at'], + questions: ['id', 'pack_id', 'text', 'partner_text', 'intensity', 'allowed_couple_genders', 'target_user_genders', 'created_at'], profiles: ['id', 'name', 'avatar_url', 'is_premium', 'couple_id', 'created_at'], couples: ['id', 'invite_code', 'created_at'], responses: ['id', 'user_id', 'question_id', 'couple_id', 'answer', 'created_at'], diff --git a/apps/admin/src/resources/questions.tsx b/apps/admin/src/resources/questions.tsx index dcf5878..5aac7f4 100755 --- a/apps/admin/src/resources/questions.tsx +++ b/apps/admin/src/resources/questions.tsx @@ -1,4 +1,64 @@ -import { List, Datagrid, TextField, ReferenceField, NumberField, EditButton, Create, SimpleForm, TextInput, NumberInput, ReferenceInput, SelectInput, Edit } from 'react-admin'; +import { + List, + Datagrid, + TextField, + ReferenceField, + NumberField, + EditButton, + Create, + SimpleForm, + TextInput, + NumberInput, + ReferenceInput, + SelectInput, + Edit, + CheckboxGroupInput, + ArrayField, + ChipField, + SingleFieldList, + BooleanField, + FunctionField, +} from 'react-admin'; +import { Typography, Box, Chip, Tooltip, IconButton } from '@mui/material'; +import HelpOutlineIcon from '@mui/icons-material/HelpOutline'; + +const COUPLE_GENDER_CHOICES = [ + { id: 'male+female', name: 'Male + Female' }, + { id: 'male+male', name: 'Male + Male' }, + { id: 'female+female', name: 'Female + Female' }, +]; + +const TARGET_USER_GENDER_CHOICES = [ + { id: 'male', name: 'Male' }, + { id: 'female', name: 'Female' }, + { id: 'non-binary', name: 'Non-binary' }, + { id: 'prefer-not-to-say', name: 'Prefer not to say' }, +]; + +const LabelWithTooltip = ({ label, tooltip }: { label: string; tooltip: string }) => ( + + {label} + + + + + + +); + +const GenderChips = ({ record, source }: { record?: any; source: string }) => { + const values = record?.[source]; + if (!values || values.length === 0) { + return ; + } + return ( + + {values.map((v: string) => ( + + ))} + + ); +}; export const QuestionList = () => ( @@ -8,31 +68,85 @@ export const QuestionList = () => ( + record?.partner_text ? 'Yes' : 'No'} + /> + { + const values = record?.target_user_genders; + if (!values || values.length === 0) return 'All'; + return values.join(', '); + }} + /> ); +const QuestionForm = () => ( + + + + + + + Question Text + + + + + + + + Targeting + + + + } + choices={TARGET_USER_GENDER_CHOICES} + helperText="Leave empty to show to all genders as initiator." + /> + + + } + choices={COUPLE_GENDER_CHOICES} + helperText="Leave empty to show to all couple types." + /> + +); + export const QuestionEdit = () => ( - - - - - - - + ); export const QuestionCreate = () => ( - - - - - - - + ); diff --git a/apps/admin/vite.config.ts b/apps/admin/vite.config.ts index 43ac69b..54cbc25 100755 --- a/apps/admin/vite.config.ts +++ b/apps/admin/vite.config.ts @@ -14,6 +14,7 @@ export default defineConfig({ port: 3001, watch: { usePolling: true, + interval: 100, }, }, }); diff --git a/apps/mobile/.env.example b/apps/mobile/.env.example index 66f5363..7961353 100644 --- a/apps/mobile/.env.example +++ b/apps/mobile/.env.example @@ -1,11 +1,13 @@ -EXPO_PUBLIC_SUPABASE_URL=your_supabase_url -EXPO_PUBLIC_SUPABASE_ANON_KEY=your_supabase_anon_key +# Supabase configuration (required) +# Get these from your Supabase project dashboard: Settings > API +EXPO_PUBLIC_SUPABASE_URL= +EXPO_PUBLIC_SUPABASE_ANON_KEY= -# Sentry error tracking (get DSN from https://sentry.io) -EXPO_PUBLIC_SENTRY_DSN=https://your-public-key@your-org.ingest.sentry.io/your-project-id +# Sentry error tracking (optional - get DSN from https://sentry.io) +EXPO_PUBLIC_SENTRY_DSN= -# RevenueCat (for Apple subscriptions) +# RevenueCat (iOS subscriptions only - Android not supported) # Get your API key from https://app.revenuecat.com -EXPO_PUBLIC_REVENUECAT_IOS_API_KEY=your_revenuecat_ios_api_key +EXPO_PUBLIC_REVENUECAT_IOS_API_KEY= # Entitlement ID from RevenueCat dashboard (defaults to "pro" if not set) EXPO_PUBLIC_REVENUECAT_ENTITLEMENT_ID=Sauci Pro diff --git a/apps/mobile/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/apps/mobile/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml index 3941bea..345888d 100644 --- a/apps/mobile/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml +++ b/apps/mobile/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -1,5 +1,6 @@ - - + + + \ No newline at end of file diff --git a/apps/mobile/app/(app)/_layout.tsx b/apps/mobile/app/(app)/_layout.tsx index b76be80..92f8b36 100755 --- a/apps/mobile/app/(app)/_layout.tsx +++ b/apps/mobile/app/(app)/_layout.tsx @@ -8,6 +8,7 @@ import { useAuthStore, useMatchStore, useMessageStore, useSubscriptionStore, use import { colors, gradients, blur, radius, spacing, typography, shadows } from "../../src/theme"; import { supabase } from "../../src/lib/supabase"; import { isBiometricEnabled } from "../../src/lib/biometricAuth"; +import { checkAndRegisterPushToken } from "../../src/lib/notifications"; import { BiometricLockScreen } from "../../src/components/BiometricLockScreen"; import type { MatchWithQuestion } from "../../src/types"; import type { Database } from "../../src/types/supabase"; @@ -182,6 +183,14 @@ export default function AppLayout() { } }, [user?.id, initializeRevenueCat]); + // Check and register push token for this device + // This handles the case where user onboarded on a different device + useEffect(() => { + if (user?.id && user?.onboarding_completed) { + checkAndRegisterPushToken(user.id); + } + }, [user?.id, user?.onboarding_completed]); + // Subscribe to realtime match notifications useEffect(() => { if (!user?.couple_id) return; diff --git a/apps/mobile/app/(app)/chat/[id].tsx b/apps/mobile/app/(app)/chat/[id].tsx index 4db1e67..d2274b1 100644 --- a/apps/mobile/app/(app)/chat/[id].tsx +++ b/apps/mobile/app/(app)/chat/[id].tsx @@ -211,7 +211,25 @@ export default function ChatScreen() { const handlePickImage = async () => { const result = await ImagePicker.launchImageLibraryAsync({ mediaTypes: ImagePicker.MediaTypeOptions.Images, - allowsEditing: true, + quality: 0.7, + }); + + if (!result.canceled) { + uploadImage(result.assets[0].uri); + } + }; + + const handleTakePhoto = async () => { + const { status } = await ImagePicker.requestCameraPermissionsAsync(); + if (status !== 'granted') { + Alert.alert( + "Camera Permission", + "Please allow camera access to take photos." + ); + return; + } + + const result = await ImagePicker.launchCameraAsync({ quality: 0.7, }); @@ -423,6 +441,7 @@ export default function ChatScreen() { onChangeText={handleTyping} onSend={handleSend} onPickImage={handlePickImage} + onTakePhoto={handleTakePhoto} /> @@ -611,23 +630,29 @@ function InputBar({ onChangeText, onSend, onPickImage, + onTakePhoto, }: { inputText: string; uploading: boolean; onChangeText: (text: string) => void; onSend: () => void; onPickImage: () => void; + onTakePhoto: () => void; }) { return ( - + {uploading ? ( ) : ( - + )} + + + + - + {item.name} {item.is_premium && ( @@ -359,7 +362,7 @@ const styles = StyleSheet.create({ }, packInfo: { flex: 1, - marginRight: spacing.sm, + marginRight: spacing.md, }, packHeader: { flexDirection: "row", @@ -370,6 +373,8 @@ const styles = StyleSheet.create({ ...typography.headline, color: colors.text, marginRight: spacing.sm, + flex: 1, + flexShrink: 1, }, packNameLocked: { color: colors.textSecondary, diff --git a/apps/mobile/app/pack/[id].tsx b/apps/mobile/app/pack/[id].tsx index 4867c4f..3b348c0 100644 --- a/apps/mobile/app/pack/[id].tsx +++ b/apps/mobile/app/pack/[id].tsx @@ -1,4 +1,4 @@ -import { View, Text, StyleSheet, FlatList, TouchableOpacity, ActivityIndicator } from "react-native"; +import { View, Text, StyleSheet, FlatList, TouchableOpacity, ActivityIndicator, Modal, Pressable } from "react-native"; import { useLocalSearchParams, useRouter } from "expo-router"; import { useEffect, useState } from "react"; import { supabase } from "../../src/lib/supabase"; @@ -6,12 +6,26 @@ import { usePacksStore } from "../../src/store"; import { Ionicons } from "@expo/vector-icons"; import { Question } from "../../src/types"; +type TooltipType = 'initiator' | 'coupleType' | null; + +const TOOLTIP_CONTENT = { + initiator: { + title: 'Initiator Filter', + description: 'The "initiator" is the first person in the couple to see and answer this question. This icon shows which genders can be the initiator. Their partner will always see the question afterward, regardless of this setting.', + }, + coupleType: { + title: 'Couple Type Filter', + description: 'This shows which couple compositions can see this question. For example, a male+female icon means the question is only shown to couples where one partner is male and one is female.', + }, +}; + export default function PackDetailsScreen() { const { id } = useLocalSearchParams(); const router = useRouter(); const { packs, fetchPacks } = usePacksStore(); const [questions, setQuestions] = useState([]); const [isLoading, setIsLoading] = useState(true); + const [activeTooltip, setActiveTooltip] = useState(null); const pack = packs.find((p) => p.id === id); @@ -57,8 +71,33 @@ export default function PackDetailsScreen() { /> ))} - {item.allowed_couple_genders && item.allowed_couple_genders.length > 0 ? ( +{/* Initiator indicator */} + {item.target_user_genders && item.target_user_genders.length > 0 ? ( + setActiveTooltip('initiator')} + > + + {item.target_user_genders.map((g, i) => ( + 0 ? { marginLeft: 4 } : {}} + /> + ))} + + + + ) : null} + {/* Couple type indicator */} + {item.allowed_couple_genders && item.allowed_couple_genders.length > 0 ? ( + setActiveTooltip('coupleType')} + > {item.allowed_couple_genders.map((g) => { const icons = []; if (g === 'male+male') { @@ -75,21 +114,26 @@ export default function PackDetailsScreen() { 0 ? { marginLeft: 2 } : {}} + style={i > 0 ? { marginLeft: 4 } : {}} /> ))} ); })} - + + ) : ( - + setActiveTooltip('coupleType')} + > - + - + + )} @@ -132,6 +176,34 @@ export default function PackDetailsScreen() { keyExtractor={(item) => item.id} contentContainerStyle={styles.list} /> + + {/* Tooltip Modal */} + setActiveTooltip(null)} + > + setActiveTooltip(null)} + > + + + {activeTooltip && TOOLTIP_CONTENT[activeTooltip].title} + + + {activeTooltip && TOOLTIP_CONTENT[activeTooltip].description} + + setActiveTooltip(null)} + > + Got it + + + + ); } @@ -233,16 +305,59 @@ const styles = StyleSheet.create({ targetsContainer: { flexDirection: 'row', flexWrap: 'wrap', - gap: 4, + alignItems: 'center', + gap: 6, }, targetBadge: { flexDirection: 'row', alignItems: 'center', backgroundColor: 'rgba(255, 255, 255, 0.1)', - paddingHorizontal: 6, - paddingVertical: 2, - borderRadius: 4, + paddingHorizontal: 10, + paddingVertical: 6, + borderRadius: 6, borderWidth: 1, borderColor: 'rgba(255, 255, 255, 0.2)', }, + initiatorBadge: { + backgroundColor: 'rgba(243, 156, 18, 0.15)', + borderColor: 'rgba(243, 156, 18, 0.3)', + }, + modalOverlay: { + flex: 1, + backgroundColor: 'rgba(0, 0, 0, 0.7)', + justifyContent: 'center', + alignItems: 'center', + padding: 24, + }, + tooltipContainer: { + backgroundColor: '#16213e', + borderRadius: 16, + padding: 24, + maxWidth: 320, + borderWidth: 1, + borderColor: '#0f3460', + }, + tooltipTitle: { + fontSize: 18, + fontWeight: 'bold', + color: '#fff', + marginBottom: 12, + }, + tooltipDescription: { + fontSize: 14, + color: '#ccc', + lineHeight: 22, + marginBottom: 20, + }, + tooltipButton: { + backgroundColor: '#e94560', + borderRadius: 8, + paddingVertical: 12, + alignItems: 'center', + }, + tooltipButtonText: { + fontSize: 16, + fontWeight: '600', + color: '#fff', + }, }); diff --git a/apps/mobile/assets/adaptive-icon.png b/apps/mobile/assets/adaptive-icon.png index 1703eb9..f3fbb44 100755 Binary files a/apps/mobile/assets/adaptive-icon.png and b/apps/mobile/assets/adaptive-icon.png differ diff --git a/apps/mobile/assets/favicon.png b/apps/mobile/assets/favicon.png index 102df6e..4a81ae5 100755 Binary files a/apps/mobile/assets/favicon.png and b/apps/mobile/assets/favicon.png differ diff --git a/apps/mobile/assets/icon.png b/apps/mobile/assets/icon.png index 1703eb9..f3fbb44 100755 Binary files a/apps/mobile/assets/icon.png and b/apps/mobile/assets/icon.png differ diff --git a/apps/mobile/assets/notification-icon.png b/apps/mobile/assets/notification-icon.png index 2df3677..45d6879 100755 Binary files a/apps/mobile/assets/notification-icon.png and b/apps/mobile/assets/notification-icon.png differ diff --git a/apps/mobile/assets/splash-icon.png b/apps/mobile/assets/splash-icon.png index 1703eb9..f3fbb44 100755 Binary files a/apps/mobile/assets/splash-icon.png and b/apps/mobile/assets/splash-icon.png differ diff --git a/apps/mobile/ios/Sauci/Images.xcassets/AppIcon.appiconset/Contents.json b/apps/mobile/ios/Sauci/Images.xcassets/AppIcon.appiconset/Contents.json index 90d8d4c..bd04914 100644 --- a/apps/mobile/ios/Sauci/Images.xcassets/AppIcon.appiconset/Contents.json +++ b/apps/mobile/ios/Sauci/Images.xcassets/AppIcon.appiconset/Contents.json @@ -1,14 +1,134 @@ { "images": [ { - "filename": "App-Icon-1024x1024@1x.png", - "idiom": "universal", - "platform": "ios", + "filename": "AppIcon@2x.png", + "idiom": "iphone", + "scale": "2x", + "size": "60x60" + }, + { + "filename": "AppIcon@3x.png", + "idiom": "iphone", + "scale": "3x", + "size": "60x60" + }, + { + "filename": "AppIcon~ipad.png", + "idiom": "ipad", + "scale": "1x", + "size": "76x76" + }, + { + "filename": "AppIcon@2x~ipad.png", + "idiom": "ipad", + "scale": "2x", + "size": "76x76" + }, + { + "filename": "AppIcon-83.5@2x~ipad.png", + "idiom": "ipad", + "scale": "2x", + "size": "83.5x83.5" + }, + { + "filename": "AppIcon-40@2x.png", + "idiom": "iphone", + "scale": "2x", + "size": "40x40" + }, + { + "filename": "AppIcon-40@3x.png", + "idiom": "iphone", + "scale": "3x", + "size": "40x40" + }, + { + "filename": "AppIcon-40~ipad.png", + "idiom": "ipad", + "scale": "1x", + "size": "40x40" + }, + { + "filename": "AppIcon-40@2x~ipad.png", + "idiom": "ipad", + "scale": "2x", + "size": "40x40" + }, + { + "filename": "AppIcon-20@2x.png", + "idiom": "iphone", + "scale": "2x", + "size": "20x20" + }, + { + "filename": "AppIcon-20@3x.png", + "idiom": "iphone", + "scale": "3x", + "size": "20x20" + }, + { + "filename": "AppIcon-20~ipad.png", + "idiom": "ipad", + "scale": "1x", + "size": "20x20" + }, + { + "filename": "AppIcon-20@2x~ipad.png", + "idiom": "ipad", + "scale": "2x", + "size": "20x20" + }, + { + "filename": "AppIcon-29.png", + "idiom": "iphone", + "scale": "1x", + "size": "29x29" + }, + { + "filename": "AppIcon-29@2x.png", + "idiom": "iphone", + "scale": "2x", + "size": "29x29" + }, + { + "filename": "AppIcon-29@3x.png", + "idiom": "iphone", + "scale": "3x", + "size": "29x29" + }, + { + "filename": "AppIcon-29~ipad.png", + "idiom": "ipad", + "scale": "1x", + "size": "29x29" + }, + { + "filename": "AppIcon-29@2x~ipad.png", + "idiom": "ipad", + "scale": "2x", + "size": "29x29" + }, + { + "filename": "AppIcon-60@2x~car.png", + "idiom": "car", + "scale": "2x", + "size": "60x60" + }, + { + "filename": "AppIcon-60@3x~car.png", + "idiom": "car", + "scale": "3x", + "size": "60x60" + }, + { + "filename": "AppIcon~ios-marketing.png", + "idiom": "ios-marketing", + "scale": "1x", "size": "1024x1024" } ], "info": { - "version": 1, - "author": "expo" + "author": "iconkitchen", + "version": 1 } } \ No newline at end of file diff --git a/apps/mobile/package-lock.json b/apps/mobile/package-lock.json index 95e8f4d..886bb1b 100644 --- a/apps/mobile/package-lock.json +++ b/apps/mobile/package-lock.json @@ -95,7 +95,6 @@ "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.5.tgz", "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==", "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.5", @@ -499,6 +498,7 @@ "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-firefox-class-in-computed-class-key/-/plugin-bugfix-firefox-class-in-computed-class-key-7.28.5.tgz", "integrity": "sha512-87GDMS3tsmMSi/3bWOte1UblL+YUTFMV8SZPZ2eSEL17s74Cw/l63rR6NmGVKMYW2GYi85nE+/d6Hw5N0bEk2Q==", "license": "MIT", + "peer": true, "dependencies": { "@babel/helper-plugin-utils": "^7.27.1", "@babel/traverse": "^7.28.5" @@ -515,6 +515,7 @@ "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-safari-class-field-initializer-scope/-/plugin-bugfix-safari-class-field-initializer-scope-7.27.1.tgz", "integrity": "sha512-qNeq3bCKnGgLkEXUuFry6dPlGfCdQNZbn7yUAPCInwAJHMU7THJfrBSozkcWq5sNM6RcF3S8XyQL2A52KNR9IA==", "license": "MIT", + "peer": true, "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, @@ -530,6 +531,7 @@ "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression/-/plugin-bugfix-safari-id-destructuring-collision-in-function-expression-7.27.1.tgz", "integrity": "sha512-g4L7OYun04N1WyqMNjldFwlfPCLVkgB54A/YCXICZYBsvJJE3kByKv9c9+R/nAfmIfjl2rKYLNyMHboYbZaWaA==", "license": "MIT", + "peer": true, "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, @@ -545,6 +547,7 @@ "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining/-/plugin-bugfix-v8-spread-parameters-in-optional-chaining-7.27.1.tgz", "integrity": "sha512-oO02gcONcD5O1iTLi/6frMJBIwWEHceWGSGqrpCmEL8nogiS6J9PBlE48CaK20/Jx1LuRml9aDftLgdjXT8+Cw==", "license": "MIT", + "peer": true, "dependencies": { "@babel/helper-plugin-utils": "^7.27.1", "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1", @@ -562,6 +565,7 @@ "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly/-/plugin-bugfix-v8-static-class-fields-redefine-readonly-7.28.3.tgz", "integrity": "sha512-b6YTX108evsvE4YgWyQ921ZAFFQm3Bn+CA3+ZXlNVnPhx+UfsVURoPjfGAPCjBgrqo30yX/C2nZGX96DxvR9Iw==", "license": "MIT", + "peer": true, "dependencies": { "@babel/helper-plugin-utils": "^7.27.1", "@babel/traverse": "^7.28.3" @@ -662,6 +666,7 @@ "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-private-property-in-object/-/plugin-proposal-private-property-in-object-7.21.0-placeholder-for-preset-env.2.tgz", "integrity": "sha512-SOSkfJDddaM7mak6cPEpswyTRnuRltl429hMraQEglW+OkovnCzsiszTmsrlY//qLFjCpQDFRvjdm2wA5pPm9w==", "license": "MIT", + "peer": true, "engines": { "node": ">=6.9.0" }, @@ -782,6 +787,7 @@ "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-assertions/-/plugin-syntax-import-assertions-7.27.1.tgz", "integrity": "sha512-UT/Jrhw57xg4ILHLFnzFpPDlMbcdEicaAtjPQpbj9wa8T4r5KVWCimHcL/460g8Ht0DMxDyjsLgiWSkVjnwPFg==", "license": "MIT", + "peer": true, "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, @@ -968,6 +974,7 @@ "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-unicode-sets-regex/-/plugin-syntax-unicode-sets-regex-7.18.6.tgz", "integrity": "sha512-727YkEAPwSIQTv5im8QHz3upqp92JTWhidIC81Tdx4VJYIte/VndKf1qKrfnnhPLiPghStWfvC/iFaMCQu7Nqg==", "license": "MIT", + "peer": true, "dependencies": { "@babel/helper-create-regexp-features-plugin": "^7.18.6", "@babel/helper-plugin-utils": "^7.18.6" @@ -1033,6 +1040,7 @@ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-block-scoped-functions/-/plugin-transform-block-scoped-functions-7.27.1.tgz", "integrity": "sha512-cnqkuOtZLapWYZUYM5rVIdv1nXYuFVIltZ6ZJ7nIj585QsjKM5dhL2Fu/lICXZ1OyIAFc7Qy+bvDAtTXqGrlhg==", "license": "MIT", + "peer": true, "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, @@ -1079,6 +1087,7 @@ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-class-static-block/-/plugin-transform-class-static-block-7.28.3.tgz", "integrity": "sha512-LtPXlBbRoc4Njl/oh1CeD/3jC+atytbnf/UqLoqTDcEYGUPj022+rvfkbDYieUrSj3CaV4yHDByPE+T2HwfsJg==", "license": "MIT", + "peer": true, "dependencies": { "@babel/helper-create-class-features-plugin": "^7.28.3", "@babel/helper-plugin-utils": "^7.27.1" @@ -1147,6 +1156,7 @@ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-dotall-regex/-/plugin-transform-dotall-regex-7.27.1.tgz", "integrity": "sha512-gEbkDVGRvjj7+T1ivxrfgygpT7GUd4vmODtYpbs0gZATdkX8/iSnOtZSxiZnsgm1YjTgjI6VKBGSJJevkrclzw==", "license": "MIT", + "peer": true, "dependencies": { "@babel/helper-create-regexp-features-plugin": "^7.27.1", "@babel/helper-plugin-utils": "^7.27.1" @@ -1163,6 +1173,7 @@ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-duplicate-keys/-/plugin-transform-duplicate-keys-7.27.1.tgz", "integrity": "sha512-MTyJk98sHvSs+cvZ4nOauwTTG1JeonDjSGvGGUNHreGQns+Mpt6WX/dVzWBHgg+dYZhkC4X+zTDfkTU+Vy9y7Q==", "license": "MIT", + "peer": true, "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, @@ -1178,6 +1189,7 @@ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-duplicate-named-capturing-groups-regex/-/plugin-transform-duplicate-named-capturing-groups-regex-7.27.1.tgz", "integrity": "sha512-hkGcueTEzuhB30B3eJCbCYeCaaEQOmQR0AdvzpD4LoN0GXMWzzGSuRrxR2xTnCrvNbVwK9N6/jQ92GSLfiZWoQ==", "license": "MIT", + "peer": true, "dependencies": { "@babel/helper-create-regexp-features-plugin": "^7.27.1", "@babel/helper-plugin-utils": "^7.27.1" @@ -1194,6 +1206,7 @@ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-dynamic-import/-/plugin-transform-dynamic-import-7.27.1.tgz", "integrity": "sha512-MHzkWQcEmjzzVW9j2q8LGjwGWpG2mjwaaB0BNQwst3FIjqsg8Ct/mIZlvSPJvfi9y2AC8mi/ktxbFVL9pZ1I4A==", "license": "MIT", + "peer": true, "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, @@ -1209,6 +1222,7 @@ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-explicit-resource-management/-/plugin-transform-explicit-resource-management-7.28.0.tgz", "integrity": "sha512-K8nhUcn3f6iB+P3gwCv/no7OdzOZQcKchW6N389V6PD8NUWKZHzndOd9sPDVbMoBsbmjMqlB4L9fm+fEFNVlwQ==", "license": "MIT", + "peer": true, "dependencies": { "@babel/helper-plugin-utils": "^7.27.1", "@babel/plugin-transform-destructuring": "^7.28.0" @@ -1225,6 +1239,7 @@ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-exponentiation-operator/-/plugin-transform-exponentiation-operator-7.28.5.tgz", "integrity": "sha512-D4WIMaFtwa2NizOp+dnoFjRez/ClKiC2BqqImwKd1X28nqBtZEyCYJ2ozQrrzlxAFrcrjxo39S6khe9RNDlGzw==", "license": "MIT", + "peer": true, "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, @@ -1304,6 +1319,7 @@ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-json-strings/-/plugin-transform-json-strings-7.27.1.tgz", "integrity": "sha512-6WVLVJiTjqcQauBhn1LkICsR2H+zm62I3h9faTDKt1qP4jn2o72tSvqMwtGFKGTpojce0gJs+76eZ2uCHRZh0Q==", "license": "MIT", + "peer": true, "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, @@ -1349,6 +1365,7 @@ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-member-expression-literals/-/plugin-transform-member-expression-literals-7.27.1.tgz", "integrity": "sha512-hqoBX4dcZ1I33jCSWcXrP+1Ku7kdqXf1oeah7ooKOIiAdKQ+uqftgCFNOSzA5AMS2XIHEYeGFg4cKRCdpxzVOQ==", "license": "MIT", + "peer": true, "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, @@ -1364,6 +1381,7 @@ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-amd/-/plugin-transform-modules-amd-7.27.1.tgz", "integrity": "sha512-iCsytMg/N9/oFq6n+gFTvUYDZQOMK5kEdeYxmxt91fcJGycfxVP9CnrxoliM0oumFERba2i8ZtwRUCMhvP1LnA==", "license": "MIT", + "peer": true, "dependencies": { "@babel/helper-module-transforms": "^7.27.1", "@babel/helper-plugin-utils": "^7.27.1" @@ -1396,6 +1414,7 @@ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-systemjs/-/plugin-transform-modules-systemjs-7.28.5.tgz", "integrity": "sha512-vn5Jma98LCOeBy/KpeQhXcV2WZgaRUtjwQmjoBuLNlOmkg0fB5pdvYVeWRYI69wWKwK2cD1QbMiUQnoujWvrew==", "license": "MIT", + "peer": true, "dependencies": { "@babel/helper-module-transforms": "^7.28.3", "@babel/helper-plugin-utils": "^7.27.1", @@ -1414,6 +1433,7 @@ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-umd/-/plugin-transform-modules-umd-7.27.1.tgz", "integrity": "sha512-iQBE/xC5BV1OxJbp6WG7jq9IWiD+xxlZhLrdwpPkTX3ydmXdvoCpyfJN7acaIBZaOqTfr76pgzqBJflNbeRK+w==", "license": "MIT", + "peer": true, "dependencies": { "@babel/helper-module-transforms": "^7.27.1", "@babel/helper-plugin-utils": "^7.27.1" @@ -1446,6 +1466,7 @@ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-new-target/-/plugin-transform-new-target-7.27.1.tgz", "integrity": "sha512-f6PiYeqXQ05lYq3TIfIDu/MtliKUbNwkGApPUvyo6+tc7uaR4cPjPe7DFPr15Uyycg2lZU6btZ575CuQoYh7MQ==", "license": "MIT", + "peer": true, "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, @@ -1510,6 +1531,7 @@ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-object-super/-/plugin-transform-object-super-7.27.1.tgz", "integrity": "sha512-SFy8S9plRPbIcxlJ8A6mT/CxFdJx/c04JEctz4jf8YZaVS2px34j7NXRrlGlHkN/M2gnpL37ZpGRGVFLd3l8Ng==", "license": "MIT", + "peer": true, "dependencies": { "@babel/helper-plugin-utils": "^7.27.1", "@babel/helper-replace-supers": "^7.27.1" @@ -1605,6 +1627,7 @@ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-property-literals/-/plugin-transform-property-literals-7.27.1.tgz", "integrity": "sha512-oThy3BCuCha8kDZ8ZkgOg2exvPYUlprMukKQXI1r1pJ47NCvxfkEy8vK+r/hT9nF0Aa4H1WUPZZjHTFtAhGfmQ==", "license": "MIT", + "peer": true, "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, @@ -1730,6 +1753,7 @@ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-regexp-modifiers/-/plugin-transform-regexp-modifiers-7.27.1.tgz", "integrity": "sha512-TtEciroaiODtXvLZv4rmfMhkCv8jx3wgKpL68PuiPh2M4fvz5jhsA7697N1gMvkvr/JTF13DrFYyEbY9U7cVPA==", "license": "MIT", + "peer": true, "dependencies": { "@babel/helper-create-regexp-features-plugin": "^7.27.1", "@babel/helper-plugin-utils": "^7.27.1" @@ -1746,6 +1770,7 @@ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-reserved-words/-/plugin-transform-reserved-words-7.27.1.tgz", "integrity": "sha512-V2ABPHIJX4kC7HegLkYoDpfg9PVmuWy/i6vUM5eGK22bx4YVFD3M5F0QQnWQoDs6AGsUWTVOopBiMFQgHaSkVw==", "license": "MIT", + "peer": true, "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, @@ -1842,6 +1867,7 @@ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-typeof-symbol/-/plugin-transform-typeof-symbol-7.27.1.tgz", "integrity": "sha512-RiSILC+nRJM7FY5srIyc4/fGIwUhyDuuBSdWn4y6yT6gm652DpCHZjIipgn6B7MQ1ITOUnAKWixEUjQRIBIcLw==", "license": "MIT", + "peer": true, "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, @@ -1876,6 +1902,7 @@ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-escapes/-/plugin-transform-unicode-escapes-7.27.1.tgz", "integrity": "sha512-Ysg4v6AmF26k9vpfFuTZg8HRfVWzsh1kVfowA23y9j/Gu6dOuahdUVhkLqpObp3JIv27MLSii6noRnuKN8H0Mg==", "license": "MIT", + "peer": true, "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, @@ -1891,6 +1918,7 @@ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-property-regex/-/plugin-transform-unicode-property-regex-7.27.1.tgz", "integrity": "sha512-uW20S39PnaTImxp39O5qFlHLS9LJEmANjMG7SxIhap8rCHqu0Ik+tLEPX5DKmHn6CsWQ7j3lix2tFOa5YtL12Q==", "license": "MIT", + "peer": true, "dependencies": { "@babel/helper-create-regexp-features-plugin": "^7.27.1", "@babel/helper-plugin-utils": "^7.27.1" @@ -1923,6 +1951,7 @@ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-sets-regex/-/plugin-transform-unicode-sets-regex-7.27.1.tgz", "integrity": "sha512-EtkOujbc4cgvb0mlpQefi4NTPBzhSIevblFevACNLUspmrALgmEBdL/XfnyyITfd8fKBZrZys92zOWcik7j9Tw==", "license": "MIT", + "peer": true, "dependencies": { "@babel/helper-create-regexp-features-plugin": "^7.27.1", "@babel/helper-plugin-utils": "^7.27.1" @@ -2041,6 +2070,7 @@ "resolved": "https://registry.npmjs.org/@babel/preset-modules/-/preset-modules-0.1.6-no-external-plugins.tgz", "integrity": "sha512-HrcgcIESLm9aIR842yhJ5RWan/gebQUJ6E/E5+rf0y9o6oj7w0Br+sWuL6kEQ/o/AdfvR1Je9jG18/gnpwjEyA==", "license": "MIT", + "peer": true, "dependencies": { "@babel/helper-plugin-utils": "^7.0.0", "@babel/types": "^7.4.4", @@ -3936,7 +3966,6 @@ "resolved": "https://registry.npmjs.org/@react-navigation/native/-/native-7.1.26.tgz", "integrity": "sha512-RhKmeD0E2ejzKS6z8elAfdfwShpcdkYY8zJzvHYLq+wv183BBcElTeyMLcIX6wIn7QutXeI92Yi21t7aUWfqNQ==", "license": "MIT", - "peer": true, "dependencies": { "@react-navigation/core": "^7.13.7", "escape-string-regexp": "^4.0.0", @@ -4608,7 +4637,6 @@ "integrity": "sha512-cisd7gxkzjBKU2GgdYrTdtQx1SORymWyaAFhaxQPK9bYO9ot3Y5OikQRvY0VYQtvwjeQnizCINJAenh/V7MK2w==", "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "@types/prop-types": "*", "csstype": "^3.2.2" @@ -4753,7 +4781,6 @@ "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", "license": "MIT", - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -5330,7 +5357,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -6510,6 +6536,7 @@ "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", "license": "BSD-2-Clause", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -6634,7 +6661,6 @@ "resolved": "https://registry.npmjs.org/expo/-/expo-52.0.48.tgz", "integrity": "sha512-/HR/vuo57KGEWlvF3GWaquwEAjXuA5hrOCsaLcZ3pMSA8mQ27qKd1jva4GWzpxXYedlzs/7LLP1XpZo6hXTsog==", "license": "MIT", - "peer": true, "dependencies": { "@babel/runtime": "^7.20.0", "@expo/cli": "0.22.27", @@ -6742,7 +6768,6 @@ "resolved": "https://registry.npmjs.org/expo-constants/-/expo-constants-17.0.8.tgz", "integrity": "sha512-XfWRyQAf1yUNgWZ1TnE8pFBMqGmFP5Gb+SFSgszxDdOoheB/NI5D4p7q86kI2fvGyfTrxAe+D+74nZkfsGvUlg==", "license": "MIT", - "peer": true, "dependencies": { "@expo/config": "~10.0.11", "@expo/env": "~0.4.2" @@ -6908,7 +6933,6 @@ "resolved": "https://registry.npmjs.org/expo-linking/-/expo-linking-7.0.5.tgz", "integrity": "sha512-3KptlJtcYDPWohk0MfJU75MJFh2ybavbtcSd84zEPfw9s1q3hjimw3sXnH03ZxP54kiEWldvKmmnGcVffBDB1g==", "license": "MIT", - "peer": true, "dependencies": { "expo-constants": "~17.0.5", "invariant": "^2.2.4" @@ -10754,7 +10778,6 @@ "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", "license": "MIT", - "peer": true, "dependencies": { "loose-envify": "^1.1.0" }, @@ -10798,7 +10821,6 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", "license": "MIT", - "peer": true, "dependencies": { "loose-envify": "^1.1.0", "scheduler": "^0.23.2" @@ -10853,7 +10875,6 @@ "resolved": "https://registry.npmjs.org/react-native/-/react-native-0.76.9.tgz", "integrity": "sha512-+LRwecWmTDco7OweGsrECIqJu0iyrREd6CTCgC/uLLYipiHvk+MH9nd6drFtCw/6Blz6eoKTcH9YTTJusNtrWg==", "license": "MIT", - "peer": true, "dependencies": { "@jest/create-cache-key-function": "^29.6.3", "@react-native/assets-registry": "0.76.9", @@ -10979,7 +11000,6 @@ "resolved": "https://registry.npmjs.org/react-native-reanimated/-/react-native-reanimated-3.16.7.tgz", "integrity": "sha512-qoUUQOwE1pHlmQ9cXTJ2MX9FQ9eHllopCLiWOkDkp6CER95ZWeXhJCP4cSm6AD4jigL5jHcZf/SkWrg8ttZUsw==", "license": "MIT", - "peer": true, "dependencies": { "@babel/plugin-transform-arrow-functions": "^7.0.0-0", "@babel/plugin-transform-class-properties": "^7.0.0-0", @@ -11004,7 +11024,6 @@ "resolved": "https://registry.npmjs.org/react-native-safe-area-context/-/react-native-safe-area-context-4.12.0.tgz", "integrity": "sha512-ukk5PxcF4p3yu6qMZcmeiZgowhb5AsKRnil54YFUUAXVIS7PJcMHGGC+q44fCiBg44/1AJk5njGMez1m9H0BVQ==", "license": "MIT", - "peer": true, "peerDependencies": { "react": "*", "react-native": "*" @@ -11015,7 +11034,6 @@ "resolved": "https://registry.npmjs.org/react-native-screens/-/react-native-screens-4.4.0.tgz", "integrity": "sha512-c7zc7Zwjty6/pGyuuvh9gK3YBYqHPOxrhXfG1lF4gHlojQSmIx2piNbNaV+Uykj+RDTmFXK0e/hA+fucw/Qozg==", "license": "MIT", - "peer": true, "dependencies": { "react-freeze": "^1.0.0", "warn-once": "^0.1.0" @@ -11042,7 +11060,6 @@ "resolved": "https://registry.npmjs.org/react-native-web/-/react-native-web-0.19.13.tgz", "integrity": "sha512-etv3bN8rJglrRCp/uL4p7l8QvUNUC++QwDbdZ8CB7BvZiMvsxfFIRM1j04vxNldG3uo2puRd6OSWR3ibtmc29A==", "license": "MIT", - "peer": true, "dependencies": { "@babel/runtime": "^7.18.6", "@react-native/normalize-colors": "^0.74.1", diff --git a/apps/mobile/src/lib/notifications.ts b/apps/mobile/src/lib/notifications.ts index 9aba2d5..1d7e602 100644 --- a/apps/mobile/src/lib/notifications.ts +++ b/apps/mobile/src/lib/notifications.ts @@ -94,6 +94,64 @@ export async function clearPushToken(userId: string): Promise { } } +/** + * Check if current device needs to register for push notifications. + * This handles the case where a user onboarded on one device (e.g., iOS) + * and then installs on another device (e.g., Android) - the new device + * needs its own push token. + * + * Returns true if a new token was registered. + */ +export async function checkAndRegisterPushToken(userId: string): Promise { + if (Platform.OS === "web") { + return false; + } + + try { + // First check if permissions are already granted + const { status } = await Notifications.getPermissionsAsync(); + + // If permissions not granted, we need to prompt the user + // But only do this if we don't already have a token for this device + if (status !== "granted") { + // Request permissions + const { status: newStatus } = await Notifications.requestPermissionsAsync(); + if (newStatus !== "granted") { + return false; + } + } + + // Get the current device's push token + const projectId = Constants.expoConfig?.extra?.eas?.projectId; + if (!projectId) { + return false; + } + + const tokenData = await Notifications.getExpoPushTokenAsync({ projectId }); + const currentToken = tokenData.data; + + // Fetch the stored token from the database + const { data: profile } = await supabase + .from("profiles") + .select("push_token") + .eq("id", userId) + .single(); + + // If the stored token is different (or null), save the new one + if (profile?.push_token !== currentToken) { + await savePushToken(userId, currentToken); + console.log("Push token updated for new device"); + return true; + } + + return false; + } catch (error) { + console.error("Failed to check/register push token:", error); + captureError(error as Error, { context: "push_token_check" }); + return false; + } +} + /** * Handle notification tap - navigate to appropriate screen. */ diff --git a/apps/mobile/src/types/index.ts b/apps/mobile/src/types/index.ts index 8de5138..d96f0eb 100755 --- a/apps/mobile/src/types/index.ts +++ b/apps/mobile/src/types/index.ts @@ -57,6 +57,7 @@ export interface Question { partner_text?: string | null; intensity: 1 | 2 | 3 | 4 | 5; allowed_couple_genders?: string[] | null; + target_user_genders?: string[] | null; created_at: string; } diff --git a/apps/supabase/migrations/20231227000001_initial_schema.sql b/apps/supabase/migrations/20231227000001_initial_schema.sql deleted file mode 100755 index 82d815d..0000000 --- a/apps/supabase/migrations/20231227000001_initial_schema.sql +++ /dev/null @@ -1,108 +0,0 @@ --- Enable necessary extensions -CREATE EXTENSION IF NOT EXISTS "uuid-ossp"; - --- Create custom types -CREATE TYPE public.answer_type AS ENUM ('yes', 'no', 'maybe'); -CREATE TYPE public.match_type AS ENUM ('yes_yes', 'yes_maybe', 'maybe_maybe'); - --- Couples table (must be created first due to FK references) -CREATE TABLE public.couples ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - invite_code TEXT UNIQUE DEFAULT substr(md5(random()::text), 1, 8), - created_at TIMESTAMPTZ DEFAULT NOW() -); - --- User profiles (extends auth.users) -CREATE TABLE public.profiles ( - id UUID PRIMARY KEY REFERENCES auth.users(id) ON DELETE CASCADE, - name TEXT, - avatar_url TEXT, - push_token TEXT, - is_premium BOOLEAN DEFAULT FALSE, - couple_id UUID REFERENCES public.couples(id) ON DELETE SET NULL, - created_at TIMESTAMPTZ DEFAULT NOW(), - updated_at TIMESTAMPTZ DEFAULT NOW() -); - --- Question packs -CREATE TABLE public.question_packs ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - name TEXT NOT NULL, - description TEXT, - icon TEXT, - is_premium BOOLEAN DEFAULT FALSE, - is_public BOOLEAN DEFAULT TRUE, - sort_order INT DEFAULT 0, - created_at TIMESTAMPTZ DEFAULT NOW() -); - --- Questions within packs -CREATE TABLE public.questions ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - pack_id UUID NOT NULL REFERENCES public.question_packs(id) ON DELETE CASCADE, - text TEXT NOT NULL, - intensity INT DEFAULT 1 CHECK (intensity BETWEEN 1 AND 5), - created_at TIMESTAMPTZ DEFAULT NOW() -); - --- User responses to questions -CREATE TABLE public.responses ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - user_id UUID NOT NULL REFERENCES public.profiles(id) ON DELETE CASCADE, - question_id UUID NOT NULL REFERENCES public.questions(id) ON DELETE CASCADE, - couple_id UUID NOT NULL REFERENCES public.couples(id) ON DELETE CASCADE, - answer public.answer_type NOT NULL, - created_at TIMESTAMPTZ DEFAULT NOW(), - UNIQUE(user_id, question_id) -); - --- Matches between partners -CREATE TABLE public.matches ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - couple_id UUID NOT NULL REFERENCES public.couples(id) ON DELETE CASCADE, - question_id UUID NOT NULL REFERENCES public.questions(id) ON DELETE CASCADE, - match_type public.match_type NOT NULL, - is_new BOOLEAN DEFAULT TRUE, - created_at TIMESTAMPTZ DEFAULT NOW(), - UNIQUE(couple_id, question_id) -); - --- Create indexes for common queries -CREATE INDEX idx_profiles_couple_id ON public.profiles(couple_id); -CREATE INDEX idx_questions_pack_id ON public.questions(pack_id); -CREATE INDEX idx_responses_couple_question ON public.responses(couple_id, question_id); -CREATE INDEX idx_matches_couple_id ON public.matches(couple_id); -CREATE INDEX idx_matches_is_new ON public.matches(is_new) WHERE is_new = TRUE; - --- Function to auto-create profile on signup -CREATE OR REPLACE FUNCTION public.handle_new_user() -RETURNS TRIGGER AS $$ -BEGIN - INSERT INTO public.profiles (id, name, avatar_url) - VALUES ( - NEW.id, - COALESCE(NEW.raw_user_meta_data->>'name', NEW.raw_user_meta_data->>'full_name'), - NEW.raw_user_meta_data->>'avatar_url' - ); - RETURN NEW; -END; -$$ LANGUAGE plpgsql SECURITY DEFINER; - --- Trigger to create profile on signup -CREATE TRIGGER on_auth_user_created - AFTER INSERT ON auth.users - FOR EACH ROW EXECUTE FUNCTION public.handle_new_user(); - --- Function to update updated_at timestamp -CREATE OR REPLACE FUNCTION public.update_updated_at() -RETURNS TRIGGER AS $$ -BEGIN - NEW.updated_at = NOW(); - RETURN NEW; -END; -$$ LANGUAGE plpgsql; - --- Trigger to auto-update updated_at on profiles -CREATE TRIGGER update_profiles_updated_at - BEFORE UPDATE ON public.profiles - FOR EACH ROW EXECUTE FUNCTION public.update_updated_at(); diff --git a/apps/supabase/migrations/20231227000002_rls_policies.sql b/apps/supabase/migrations/20231227000002_rls_policies.sql deleted file mode 100755 index fac1a74..0000000 --- a/apps/supabase/migrations/20231227000002_rls_policies.sql +++ /dev/null @@ -1,87 +0,0 @@ --- Enable Row Level Security on all tables -ALTER TABLE public.profiles ENABLE ROW LEVEL SECURITY; -ALTER TABLE public.couples ENABLE ROW LEVEL SECURITY; -ALTER TABLE public.question_packs ENABLE ROW LEVEL SECURITY; -ALTER TABLE public.questions ENABLE ROW LEVEL SECURITY; -ALTER TABLE public.responses ENABLE ROW LEVEL SECURITY; -ALTER TABLE public.matches ENABLE ROW LEVEL SECURITY; - --- Profiles policies -CREATE POLICY "Users can view own profile" - ON public.profiles FOR SELECT - USING (auth.uid() = id); - -CREATE POLICY "Users can update own profile" - ON public.profiles FOR UPDATE - USING (auth.uid() = id); - -CREATE POLICY "Users can view partner profile" - ON public.profiles FOR SELECT - USING ( - couple_id IS NOT NULL AND - couple_id IN (SELECT couple_id FROM public.profiles WHERE id = auth.uid()) - ); - --- Couples policies -CREATE POLICY "Users can view own couple" - ON public.couples FOR SELECT - USING (id IN (SELECT couple_id FROM public.profiles WHERE id = auth.uid())); - -CREATE POLICY "Authenticated users can create couples" - ON public.couples FOR INSERT - WITH CHECK (auth.role() = 'authenticated'); - -CREATE POLICY "Users can view couple by invite code" - ON public.couples FOR SELECT - USING (TRUE); -- Allow reading by invite_code for joining - --- Question packs policies (public packs visible to all) -CREATE POLICY "Anyone can view public packs" - ON public.question_packs FOR SELECT - USING (is_public = TRUE); - -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 id = auth.uid() AND is_premium = TRUE) - ); - --- Questions policies -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 id = auth.uid() AND is_premium = TRUE) - ) - ) - ); - --- Responses policies -CREATE POLICY "Users can view own responses" - ON public.responses FOR SELECT - USING (user_id = auth.uid()); - -CREATE POLICY "Users can insert own responses" - ON public.responses FOR INSERT - WITH CHECK (user_id = auth.uid()); - -CREATE POLICY "Users can update own responses" - ON public.responses FOR UPDATE - USING (user_id = auth.uid()); - --- Matches policies -CREATE POLICY "Users can view couple matches" - ON public.matches FOR SELECT - USING ( - couple_id IN (SELECT couple_id FROM public.profiles WHERE id = auth.uid()) - ); - -CREATE POLICY "Users can update match seen status" - ON public.matches FOR UPDATE - USING ( - couple_id IN (SELECT couple_id FROM public.profiles WHERE id = auth.uid()) - ); diff --git a/apps/supabase/migrations/20231227000003_seed_packs.sql b/apps/supabase/migrations/20231227000003_seed_packs.sql deleted file mode 100755 index c93a661..0000000 --- a/apps/supabase/migrations/20231227000003_seed_packs.sql +++ /dev/null @@ -1,58 +0,0 @@ --- Seed data: Initial question packs for testing - --- Starter Pack (free) -INSERT INTO public.question_packs (id, name, description, icon, is_premium, is_public, sort_order) -VALUES ( - 'a1b2c3d4-e5f6-7890-abcd-ef1234567890', - 'Starter Pack', - 'Perfect for couples just getting started. Light and playful questions to break the ice.', - '🌟', - FALSE, - TRUE, - 1 -); - -INSERT INTO public.questions (pack_id, text, intensity) VALUES - ('a1b2c3d4-e5f6-7890-abcd-ef1234567890', 'Would you like to try a couples massage together?', 1), - ('a1b2c3d4-e5f6-7890-abcd-ef1234567890', 'Would you enjoy a candlelit dinner at home?', 1), - ('a1b2c3d4-e5f6-7890-abcd-ef1234567890', 'Would you like to slow dance in the living room?', 1), - ('a1b2c3d4-e5f6-7890-abcd-ef1234567890', 'Would you enjoy a bath or shower together?', 2), - ('a1b2c3d4-e5f6-7890-abcd-ef1234567890', 'Would you like to give your partner a back rub?', 1); - --- Date Night Pack (free) -INSERT INTO public.question_packs (id, name, description, icon, is_premium, is_public, sort_order) -VALUES ( - 'b2c3d4e5-f678-9012-bcde-f23456789012', - 'Date Night', - 'Spice up your date nights with these romantic suggestions.', - '🌹', - FALSE, - TRUE, - 2 -); - -INSERT INTO public.questions (pack_id, text, intensity) VALUES - ('b2c3d4e5-f678-9012-bcde-f23456789012', 'Would you enjoy stargazing together?', 1), - ('b2c3d4e5-f678-9012-bcde-f23456789012', 'Would you like to cook a meal together?', 1), - ('b2c3d4e5-f678-9012-bcde-f23456789012', 'Would you enjoy a picnic in the park?', 1), - ('b2c3d4e5-f678-9012-bcde-f23456789012', 'Would you like to watch a romantic movie together?', 1), - ('b2c3d4e5-f678-9012-bcde-f23456789012', 'Would you enjoy a weekend getaway?', 2); - --- Adventure Pack (premium) -INSERT INTO public.question_packs (id, name, description, icon, is_premium, is_public, sort_order) -VALUES ( - 'c3d4e5f6-7890-1234-cdef-345678901234', - 'Adventure', - 'For couples ready to explore more adventurous activities together.', - '🔥', - TRUE, - TRUE, - 3 -); - -INSERT INTO public.questions (pack_id, text, intensity) VALUES - ('c3d4e5f6-7890-1234-cdef-345678901234', 'Would you like to try role-playing scenarios?', 3), - ('c3d4e5f6-7890-1234-cdef-345678901234', 'Would you enjoy using massage oils?', 2), - ('c3d4e5f6-7890-1234-cdef-345678901234', 'Would you like to write love letters to each other?', 2), - ('c3d4e5f6-7890-1234-cdef-345678901234', 'Would you enjoy a blindfolded experience?', 3), - ('c3d4e5f6-7890-1234-cdef-345678901234', 'Would you like to plan a surprise for your partner?', 2); diff --git a/apps/supabase/migrations/20231227000004_adult_pack.sql b/apps/supabase/migrations/20231227000004_adult_pack.sql deleted file mode 100755 index 5247b70..0000000 --- a/apps/supabase/migrations/20231227000004_adult_pack.sql +++ /dev/null @@ -1,32 +0,0 @@ --- Add Adult / After Dark pack -INSERT INTO public.question_packs (id, name, description, icon, is_premium, is_public, sort_order) -VALUES ( - 'd4e5f607-8901-2345-defa-456789012345', - 'After Dark', - 'Our most intimate and explicit questions for couples ready to explore their deepest fantasies.', - '😈', - TRUE, - TRUE, - 4 -); - -INSERT INTO public.questions (pack_id, text, intensity) VALUES - ('d4e5f607-8901-2345-defa-456789012345', 'Would you like to try using toys during intimacy?', 3), - ('d4e5f607-8901-2345-defa-456789012345', 'Would you enjoy exploring BDSM (e.g., light bondage)?', 3), - ('d4e5f607-8901-2345-defa-456789012345', 'Would you like to watch adult content together?', 3), - ('d4e5f607-8901-2345-defa-456789012345', 'Would you enjoy trying intimacy in a new or daring location?', 3), - ('d4e5f607-8901-2345-defa-456789012345', 'Would you like to experiment with temperature play (e.g., ice or wax)?', 3), - ('d4e5f607-8901-2345-defa-456789012345', 'Would you enjoy role-playing a "stranger" or "first date" scenario?', 3), - ('d4e5f607-8901-2345-defa-456789012345', 'Would you like to try "dirty talk" or more explicit communication?', 3), - ('d4e5f607-8901-2345-defa-456789012345', 'Would you enjoy a full-body sensory massage with oils and feathers?', 3), - ('d4e5f607-8901-2345-defa-456789012345', 'Would you like to explore "edging" or delayed gratification?', 3), - ('d4e5f607-8901-2345-defa-456789012345', 'Would you enjoy sharing your deepest, most explicit fantasies?', 3), - ('d4e5f607-8901-2345-defa-456789012345', 'Would you like to try shower sex or water play?', 3), - ('d4e5f607-8901-2345-defa-456789012345', 'Would you enjoy a roleplay involving a "student/teacher" or similar dynamic?', 3), - ('d4e5f607-8901-2345-defa-456789012345', 'Would you like to experiment with leather, latex, or other textures?', 3), - ('d4e5f607-8901-2345-defa-456789012345', 'Would you enjoy trying "impact play" (e.g., spanking)?', 3), - ('d4e5f607-8901-2345-defa-456789012345', 'Would you enjoy a weekend dedicated entirely to intimacy and exploration?', 2), - ('d4e5f607-8901-2345-defa-456789012345', 'Would you like to try "blindfolded feeding" or sensory food play?', 2), - ('d4e5f607-8901-2345-defa-456789012345', 'Would you enjoy exploring your partner''s "no-go" zones with permission?', 3), - ('d4e5f607-8901-2345-defa-456789012345', 'Would you like to try "synchronized breathing" or Tantric techniques?', 2), - ('d4e5f607-8901-2345-defa-456789012345', 'Would you enjoy making a "video for us" to watch later?', 3); diff --git a/apps/supabase/migrations/20231227000005_fix_rls_recursion.sql b/apps/supabase/migrations/20231227000005_fix_rls_recursion.sql deleted file mode 100644 index 5ff73ef..0000000 --- a/apps/supabase/migrations/20231227000005_fix_rls_recursion.sql +++ /dev/null @@ -1,49 +0,0 @@ --- Fix infinite recursion in profiles RLS policy - --- Create a secure function to look up couple_id without triggering recursion -CREATE OR REPLACE FUNCTION public.get_auth_user_couple_id() -RETURNS UUID -LANGUAGE sql -SECURITY DEFINER -STABLE -AS $$ - SELECT couple_id FROM public.profiles WHERE id = auth.uid(); -$$; - --- Drop the recursive policy -DROP POLICY IF EXISTS "Users can view partner profile" ON public.profiles; - --- Recreate the policy using the secure function -CREATE POLICY "Users can view partner profile" - ON public.profiles FOR SELECT - USING ( - couple_id IS NOT NULL AND - couple_id = public.get_auth_user_couple_id() - ); - --- Also fix the Matches policy which has the same issue -DROP POLICY IF EXISTS "Users can view couple matches" ON public.matches; - -CREATE POLICY "Users can view couple matches" - ON public.matches FOR SELECT - USING ( - couple_id = public.get_auth_user_couple_id() - ); - --- And the update matches policy -DROP POLICY IF EXISTS "Users can update match seen status" ON public.matches; - -CREATE POLICY "Users can update match seen status" - ON public.matches FOR UPDATE - USING ( - couple_id = public.get_auth_user_couple_id() - ); - --- And the Couples policy -DROP POLICY IF EXISTS "Users can view own couple" ON public.couples; - -CREATE POLICY "Users can view own couple" - ON public.couples FOR SELECT - USING ( - id = public.get_auth_user_couple_id() - ); diff --git a/apps/supabase/migrations/20231227000006_couple_packs.sql b/apps/supabase/migrations/20231227000006_couple_packs.sql deleted file mode 100644 index a739381..0000000 --- a/apps/supabase/migrations/20231227000006_couple_packs.sql +++ /dev/null @@ -1,41 +0,0 @@ --- Create couple_packs table to track enabled packs per couple -CREATE TABLE public.couple_packs ( - couple_id UUID NOT NULL REFERENCES public.couples(id) ON DELETE CASCADE, - pack_id UUID NOT NULL REFERENCES public.question_packs(id) ON DELETE CASCADE, - enabled BOOLEAN DEFAULT TRUE, - created_at TIMESTAMPTZ DEFAULT NOW(), - PRIMARY KEY (couple_id, pack_id) -); - --- RLS Policies -ALTER TABLE public.couple_packs ENABLE ROW LEVEL SECURITY; - --- Couples can view their own pack settings -CREATE POLICY "Couples can view their own pack settings" - ON public.couple_packs - FOR SELECT - USING ( - EXISTS ( - SELECT 1 FROM public.profiles - WHERE profiles.id = auth.uid() - AND profiles.couple_id = couple_packs.couple_id - ) - ); - --- Couples can insert/update their own pack settings -CREATE POLICY "Couples can modify their own pack settings" - ON public.couple_packs - FOR ALL - USING ( - EXISTS ( - SELECT 1 FROM public.profiles - WHERE profiles.id = auth.uid() - AND profiles.couple_id = couple_packs.couple_id - ) - ); - --- Function to handle default packs for new couples (Optional, but good UX) --- For now, we'll just assume if no record exists, it's not enabled? --- Or maybe better: if no record exists, it IS enabled if it's not premium? --- Actually, the user wants to "select which packs are active". --- Let's stick to explicit records. diff --git a/apps/supabase/migrations/20231227000008_chat_system.sql b/apps/supabase/migrations/20231227000008_chat_system.sql deleted file mode 100644 index f9c1bf0..0000000 --- a/apps/supabase/migrations/20231227000008_chat_system.sql +++ /dev/null @@ -1,64 +0,0 @@ --- Create messages table -CREATE TABLE IF NOT EXISTS public.messages ( - id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), - match_id UUID NOT NULL REFERENCES public.matches(id) ON DELETE CASCADE, - user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE, - content TEXT, - media_path TEXT, - created_at TIMESTAMPTZ DEFAULT NOW(), - CONSTRAINT content_or_media CHECK (content IS NOT NULL OR media_path IS NOT NULL) -); - --- RLS for messages -ALTER TABLE public.messages ENABLE ROW LEVEL SECURITY; - -CREATE POLICY "Users can view messages in their matches" - ON public.messages - FOR SELECT - USING ( - EXISTS ( - SELECT 1 FROM public.matches m - WHERE m.id = messages.match_id - AND m.couple_id IN ( - SELECT couple_id FROM public.profiles - WHERE id = auth.uid() - ) - ) - ); - -CREATE POLICY "Users can insert messages in their matches" - ON public.messages - FOR INSERT - WITH CHECK ( - auth.uid() = user_id AND - EXISTS ( - SELECT 1 FROM public.matches m - WHERE m.id = messages.match_id - AND m.couple_id IN ( - SELECT couple_id FROM public.profiles - WHERE id = auth.uid() - ) - ) - ); - --- Storage bucket for chat media -INSERT INTO storage.buckets (id, name, public) -VALUES ('chat-media', 'chat-media', true) -ON CONFLICT (id) DO NOTHING; - --- Storage policies -CREATE POLICY "Users can upload chat media" - ON storage.objects - FOR INSERT - WITH CHECK ( - bucket_id = 'chat-media' AND - auth.role() = 'authenticated' - ); - -CREATE POLICY "Users can view chat media" - ON storage.objects - FOR SELECT - USING (bucket_id = 'chat-media'); - --- Realtime -alter publication supabase_realtime add table messages; diff --git a/apps/supabase/migrations/20231227000009_chat_updates.sql b/apps/supabase/migrations/20231227000009_chat_updates.sql deleted file mode 100644 index 0f413c9..0000000 --- a/apps/supabase/migrations/20231227000009_chat_updates.sql +++ /dev/null @@ -1,21 +0,0 @@ --- Add read_at column -ALTER TABLE public.messages ADD COLUMN IF NOT EXISTS read_at TIMESTAMPTZ; - --- Allow users to update read_at for messages where they are NOT the sender -CREATE POLICY "Users can mark messages as read" - ON public.messages - FOR UPDATE - USING ( - auth.uid() != user_id AND - EXISTS ( - SELECT 1 FROM public.matches m - WHERE m.id = messages.match_id - AND m.couple_id IN ( - SELECT couple_id FROM public.profiles - WHERE id = auth.uid() - ) - ) - ) - WITH CHECK ( - auth.uid() != user_id - ); diff --git a/apps/supabase/migrations/20231227000010_explicit_pack.sql b/apps/supabase/migrations/20231227000010_explicit_pack.sql deleted file mode 100644 index 2e7a3d2..0000000 --- a/apps/supabase/migrations/20231227000010_explicit_pack.sql +++ /dev/null @@ -1,33 +0,0 @@ --- Add Explicit / Wild pack -INSERT INTO public.question_packs (id, name, description, icon, is_premium, is_public, sort_order) -VALUES ( - 'e5f6a7b8-9012-3456-abcd-567890123456', - 'Explicit & Wild', - 'Unleash your wildest desires with our most explicit questions. Uses direct language for couples who want to get straight to the point.', - '🔥', - TRUE, - TRUE, - 5 -); - -INSERT INTO public.questions (pack_id, text, intensity) VALUES - ('e5f6a7b8-9012-3456-abcd-567890123456', 'Would you let me cum on your face?', 5), - ('e5f6a7b8-9012-3456-abcd-567890123456', 'Do you want to swallow my cum?', 5), - ('e5f6a7b8-9012-3456-abcd-567890123456', 'Would you like to be fucked rough, with hair pulling and choking?', 5), - ('e5f6a7b8-9012-3456-abcd-567890123456', 'Do you want to use a butt plug during sex?', 4), - ('e5f6a7b8-9012-3456-abcd-567890123456', 'Would you let me treat you like a slut in the bedroom?', 5), - ('e5f6a7b8-9012-3456-abcd-567890123456', 'Do you want to make a sex tape where we show everything?', 4), - ('e5f6a7b8-9012-3456-abcd-567890123456', 'Would you be into a gangbang fantasy?', 5), - ('e5f6a7b8-9012-3456-abcd-567890123456', 'Do you want a creampie (cum inside)?', 5), - ('e5f6a7b8-9012-3456-abcd-567890123456', 'Would you like to use a strap-on to fuck me?', 5), - ('e5f6a7b8-9012-3456-abcd-567890123456', 'Do you want to rim my asshole?', 5), - ('e5f6a7b8-9012-3456-abcd-567890123456', 'Do you want to try fisting?', 5), - ('e5f6a7b8-9012-3456-abcd-567890123456', 'Would you lick my cum off your own body?', 5), - ('e5f6a7b8-9012-3456-abcd-567890123456', 'Do you want to deep throat until you gag?', 5), - ('e5f6a7b8-9012-3456-abcd-567890123456', 'Would you like to watch me have sex with someone else?', 5), - ('e5f6a7b8-9012-3456-abcd-567890123456', 'Do you want to have anal sex until I cum?', 5), - ('e5f6a7b8-9012-3456-abcd-567890123456', 'Would you be down for a threesome?', 5), - ('e5f6a7b8-9012-3456-abcd-567890123456', 'Do you want me to edge you until you are begging to cum?', 4), - ('e5f6a7b8-9012-3456-abcd-567890123456', 'Would you like to wake me up with oral sex?', 4), - ('e5f6a7b8-9012-3456-abcd-567890123456', 'Do you want to be spanked hard enough to leave marks?', 4), - ('e5f6a7b8-9012-3456-abcd-567890123456', 'Would you enjoy being tied up and helpless while I use you?', 5); diff --git a/apps/supabase/migrations/20251227170500_smart_recommendations.sql b/apps/supabase/migrations/20251227170500_smart_recommendations.sql deleted file mode 100644 index 3c85202..0000000 --- a/apps/supabase/migrations/20251227170500_smart_recommendations.sql +++ /dev/null @@ -1,70 +0,0 @@ -CREATE OR REPLACE FUNCTION public.get_recommended_questions(target_pack_id UUID DEFAULT NULL) -RETURNS TABLE ( - id UUID, - text TEXT, - pack_id UUID, - intensity INT, - partner_answered BOOLEAN -) LANGUAGE plpgsql SECURITY DEFINER AS $$ -DECLARE - v_user_id UUID; - v_couple_id UUID; -BEGIN - v_user_id := auth.uid(); - - -- Get couple_id - SELECT p.couple_id INTO v_couple_id - FROM public.profiles p - WHERE p.id = v_user_id; - - IF v_couple_id IS NULL THEN - RETURN; - END IF; - - RETURN QUERY - WITH user_config_exists AS ( - SELECT 1 FROM public.couple_packs WHERE couple_id = v_couple_id LIMIT 1 - ), - active_packs AS ( - -- Case 0: Specific pack requested - SELECT target_pack_id AS pack_id - WHERE target_pack_id IS NOT NULL - - UNION ALL - - -- Case 1: No specific pack, config exists -> use enabled packs - SELECT cp.pack_id - FROM public.couple_packs cp - WHERE target_pack_id IS NULL - AND cp.couple_id = v_couple_id AND cp.enabled = TRUE - AND EXISTS (SELECT 1 FROM user_config_exists) - - UNION ALL - - -- Case 2: No specific pack, no config -> use all public packs - SELECT p.id AS pack_id - FROM public.question_packs p - WHERE target_pack_id IS NULL - AND p.is_public = TRUE - AND NOT EXISTS (SELECT 1 FROM user_config_exists) - ) - SELECT - q.id, - q.text, - 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 - 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 - ); -END; -$$; diff --git a/apps/supabase/migrations/20251227171000_two_part_questions.sql b/apps/supabase/migrations/20251227171000_two_part_questions.sql deleted file mode 100644 index ecf5d16..0000000 --- a/apps/supabase/migrations/20251227171000_two_part_questions.sql +++ /dev/null @@ -1,79 +0,0 @@ --- Add partner_text column to questions table -ALTER TABLE public.questions ADD COLUMN IF NOT EXISTS partner_text TEXT; - --- Update get_recommended_questions to include partner_text --- Must drop first because return type changes -DROP FUNCTION IF EXISTS public.get_recommended_questions(uuid); - -CREATE OR REPLACE FUNCTION public.get_recommended_questions(target_pack_id UUID DEFAULT NULL) -RETURNS TABLE ( - id UUID, - text TEXT, - partner_text TEXT, - pack_id UUID, - intensity INT, - partner_answered BOOLEAN -) LANGUAGE plpgsql SECURITY DEFINER AS $$ -DECLARE - v_user_id UUID; - v_couple_id UUID; -BEGIN - v_user_id := auth.uid(); - - -- Get couple_id - SELECT p.couple_id INTO v_couple_id - FROM public.profiles p - WHERE p.id = v_user_id; - - IF v_couple_id IS NULL THEN - RETURN; - END IF; - - RETURN QUERY - WITH user_config_exists AS ( - SELECT 1 FROM public.couple_packs WHERE couple_id = v_couple_id LIMIT 1 - ), - active_packs AS ( - -- Case 0: Specific pack requested - SELECT target_pack_id AS pack_id - WHERE target_pack_id IS NOT NULL - - UNION ALL - - -- Case 1: No specific pack, config exists -> use enabled packs - SELECT cp.pack_id - FROM public.couple_packs cp - WHERE target_pack_id IS NULL - AND cp.couple_id = v_couple_id AND cp.enabled = TRUE - AND EXISTS (SELECT 1 FROM user_config_exists) - - UNION ALL - - -- Case 2: No specific pack, no config -> use all public packs - SELECT p.id AS pack_id - FROM public.question_packs p - WHERE target_pack_id IS NULL - AND p.is_public = TRUE - AND NOT EXISTS (SELECT 1 FROM user_config_exists) - ) - SELECT - q.id, - q.text, - q.partner_text, - 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 - 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 - ); -END; -$$; diff --git a/apps/supabase/migrations/20251227171100_seed_two_part_questions.sql b/apps/supabase/migrations/20251227171100_seed_two_part_questions.sql deleted file mode 100644 index cbaab7d..0000000 --- a/apps/supabase/migrations/20251227171100_seed_two_part_questions.sql +++ /dev/null @@ -1,18 +0,0 @@ --- Add a new pack for two-part questions -INSERT INTO public.question_packs (id, name, description, icon, is_premium, is_public, sort_order) -VALUES ( - 'd4e5f6f7-8901-2345-defa-456789012345', - 'Giving & Receiving', - 'Explore different roles and dynamics with these two-part questions.', - '🌓', - FALSE, - TRUE, - 4 -) ON CONFLICT (id) DO NOTHING; - -INSERT INTO public.questions (pack_id, text, partner_text, intensity) VALUES - ('d4e5f6f7-8901-2345-defa-456789012345', 'Finish on your partner''s face', 'Have your partner finish on your face', 4), - ('d4e5f6f7-8901-2345-defa-456789012345', 'Give your partner a full body massage', 'Receive a full body massage from your partner', 2), - ('d4e5f6f7-8901-2345-defa-456789012345', 'Tie your partner up', 'Be tied up by your partner', 4), - ('d4e5f6f7-8901-2345-defa-456789012345', 'Blindfold your partner', 'Be blindfolded by your partner', 3), - ('d4e5f6f7-8901-2345-defa-456789012345', 'Feed your partner while they are blindfolded', 'Be fed by your partner while blindfolded', 2); diff --git a/apps/supabase/migrations/20251227172500_role_specific_questions.sql b/apps/supabase/migrations/20251227172500_role_specific_questions.sql deleted file mode 100644 index f9a7259..0000000 --- a/apps/supabase/migrations/20251227172500_role_specific_questions.sql +++ /dev/null @@ -1,75 +0,0 @@ --- Update get_recommended_questions to handle two-part questions role assignment --- And return is_two_part for prioritization in the app -DROP FUNCTION IF EXISTS public.get_recommended_questions(uuid); - -CREATE OR REPLACE FUNCTION public.get_recommended_questions(target_pack_id UUID DEFAULT NULL) -RETURNS TABLE ( - id UUID, - text TEXT, - partner_text TEXT, -- Keep this for reference if needed, but 'text' will be the display one - is_two_part BOOLEAN, - pack_id UUID, - intensity INT, - partner_answered BOOLEAN -) LANGUAGE plpgsql SECURITY DEFINER AS $$ -DECLARE - v_user_id UUID; - v_couple_id UUID; -BEGIN - v_user_id := auth.uid(); - - -- Get couple_id - SELECT p.couple_id INTO v_couple_id - FROM public.profiles p - WHERE p.id = v_user_id; - - IF v_couple_id IS NULL THEN - RETURN; - END IF; - - 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 - UNION ALL - SELECT cp.pack_id FROM public.couple_packs cp - WHERE target_pack_id IS NULL AND cp.couple_id = v_couple_id AND cp.enabled = 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 NOT EXISTS (SELECT 1 FROM user_config_exists) - ) - SELECT - q.id, - -- Logic: Show partner_text IF partner has already answered, otherwise show text - 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 - 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 - ); -END; -$$; diff --git a/apps/supabase/migrations/20251227173000_update_responses_rls.sql b/apps/supabase/migrations/20251227173000_update_responses_rls.sql deleted file mode 100644 index 788b872..0000000 --- a/apps/supabase/migrations/20251227173000_update_responses_rls.sql +++ /dev/null @@ -1,9 +0,0 @@ --- Allow users to view their partner's responses in addition to their own -DROP POLICY IF EXISTS "Users can view own responses" ON public.responses; - -CREATE POLICY "Users can view couple responses" - ON public.responses FOR SELECT - USING ( - user_id = auth.uid() OR - couple_id IN (SELECT couple_id FROM public.profiles WHERE id = auth.uid()) - ); diff --git a/apps/supabase/migrations/20251228000001_feedback_system.sql b/apps/supabase/migrations/20251228000001_feedback_system.sql deleted file mode 100644 index a310965..0000000 --- a/apps/supabase/migrations/20251228000001_feedback_system.sql +++ /dev/null @@ -1,74 +0,0 @@ --- Create feedback type enum -CREATE TYPE public.feedback_type AS ENUM ('bug', 'feature_request', 'general'); - --- Create feedback status enum (for future admin use) -CREATE TYPE public.feedback_status AS ENUM ('new', 'reviewed', 'in_progress', 'resolved', 'closed'); - --- Create feedback table -CREATE TABLE public.feedback ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - user_id UUID NOT NULL REFERENCES public.profiles(id) ON DELETE CASCADE, - - -- Feedback content - type public.feedback_type NOT NULL, - title TEXT NOT NULL, - description TEXT NOT NULL, - screenshot_url TEXT, - - -- Device info (JSONB for flexibility) - device_info JSONB NOT NULL DEFAULT '{}', - - -- Admin/tracking fields - status public.feedback_status NOT NULL DEFAULT 'new', - admin_notes TEXT, - - created_at TIMESTAMPTZ DEFAULT NOW(), - updated_at TIMESTAMPTZ DEFAULT NOW() -); - --- Create indexes -CREATE INDEX idx_feedback_user_id ON public.feedback(user_id); -CREATE INDEX idx_feedback_type ON public.feedback(type); -CREATE INDEX idx_feedback_status ON public.feedback(status); -CREATE INDEX idx_feedback_created_at ON public.feedback(created_at DESC); - --- Enable RLS -ALTER TABLE public.feedback ENABLE ROW LEVEL SECURITY; - --- RLS Policies --- Users can insert their own feedback -CREATE POLICY "Users can create feedback" - ON public.feedback FOR INSERT - WITH CHECK (auth.uid() = user_id); - --- Users can view their own feedback -CREATE POLICY "Users can view own feedback" - ON public.feedback FOR SELECT - USING (auth.uid() = user_id); - --- Users can update their own feedback (only if still 'new' status) -CREATE POLICY "Users can update own pending feedback" - ON public.feedback FOR UPDATE - USING (auth.uid() = user_id AND status = 'new'); - --- Create storage bucket for feedback screenshots -INSERT INTO storage.buckets (id, name, public) -VALUES ('feedback-screenshots', 'feedback-screenshots', false) -ON CONFLICT (id) DO NOTHING; - --- Storage RLS policies for feedback screenshots -CREATE POLICY "Users can upload feedback screenshots" - ON storage.objects FOR INSERT - TO authenticated - WITH CHECK ( - bucket_id = 'feedback-screenshots' AND - (storage.foldername(name))[1] = auth.uid()::text - ); - -CREATE POLICY "Users can view own feedback screenshots" - ON storage.objects FOR SELECT - TO authenticated - USING ( - bucket_id = 'feedback-screenshots' AND - (storage.foldername(name))[1] = auth.uid()::text - ); diff --git a/apps/supabase/migrations/20251229000001_subscriptions.sql b/apps/supabase/migrations/20251229000001_subscriptions.sql deleted file mode 100644 index ab08cb4..0000000 --- a/apps/supabase/migrations/20251229000001_subscriptions.sql +++ /dev/null @@ -1,84 +0,0 @@ --- Create subscription status enum -CREATE TYPE public.subscription_status AS ENUM ( - 'active', - 'cancelled', - 'expired', - 'billing_issue', - 'paused' -); - --- Create subscriptions table -CREATE TABLE public.subscriptions ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - user_id UUID NOT NULL REFERENCES public.profiles(id) ON DELETE CASCADE, - revenuecat_app_user_id TEXT NOT NULL, - product_id TEXT NOT NULL, - status public.subscription_status NOT NULL DEFAULT 'active', - entitlement_ids TEXT[] DEFAULT '{}', - purchased_at TIMESTAMPTZ NOT NULL, - expires_at TIMESTAMPTZ, - original_transaction_id TEXT, - store TEXT DEFAULT 'APP_STORE', - is_sandbox BOOLEAN DEFAULT FALSE, - cancel_reason TEXT, - grace_period_expires_at TIMESTAMPTZ, - created_at TIMESTAMPTZ DEFAULT NOW(), - updated_at TIMESTAMPTZ DEFAULT NOW(), - UNIQUE(user_id, original_transaction_id) -); - --- Create webhook events table for idempotency -CREATE TABLE public.revenuecat_webhook_events ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - event_id TEXT UNIQUE NOT NULL, - event_type TEXT NOT NULL, - app_user_id TEXT NOT NULL, - processed_at TIMESTAMPTZ DEFAULT NOW(), - payload JSONB -); - --- Indexes -CREATE INDEX idx_subscriptions_user_id ON public.subscriptions(user_id); -CREATE INDEX idx_subscriptions_status ON public.subscriptions(status); -CREATE INDEX idx_subscriptions_expires_at ON public.subscriptions(expires_at); -CREATE INDEX idx_webhook_events_event_id ON public.revenuecat_webhook_events(event_id); - --- Update updated_at trigger for subscriptions -CREATE TRIGGER update_subscriptions_updated_at - BEFORE UPDATE ON public.subscriptions - FOR EACH ROW EXECUTE FUNCTION public.update_updated_at(); - --- Enable RLS -ALTER TABLE public.subscriptions ENABLE ROW LEVEL SECURITY; -ALTER TABLE public.revenuecat_webhook_events ENABLE ROW LEVEL SECURITY; - --- RLS Policies for subscriptions -CREATE POLICY "Users can view own subscriptions" - ON public.subscriptions FOR SELECT - USING (auth.uid() = user_id); - --- Service role only for webhook events (no user access needed) --- Webhook events are written by service role only - --- Function to sync is_premium flag to profiles -CREATE OR REPLACE FUNCTION public.sync_premium_status() -RETURNS TRIGGER AS $$ -BEGIN - -- Update the profile's is_premium based on active subscription - UPDATE public.profiles - SET is_premium = EXISTS ( - SELECT 1 FROM public.subscriptions - WHERE user_id = NEW.user_id - AND status = 'active' - AND (expires_at IS NULL OR expires_at > NOW()) - ) - WHERE id = NEW.user_id; - - RETURN NEW; -END; -$$ LANGUAGE plpgsql SECURITY DEFINER; - --- Trigger to auto-sync premium status on subscription changes -CREATE TRIGGER sync_premium_on_subscription_change - AFTER INSERT OR UPDATE ON public.subscriptions - FOR EACH ROW EXECUTE FUNCTION public.sync_premium_status(); diff --git a/apps/supabase/migrations/20251229000002_premium_partner_access.sql b/apps/supabase/migrations/20251229000002_premium_partner_access.sql deleted file mode 100644 index bdb8f49..0000000 --- a/apps/supabase/migrations/20251229000002_premium_partner_access.sql +++ /dev/null @@ -1,91 +0,0 @@ --- Update RLS policies to allow partner premium access --- When either user OR their partner has is_premium = TRUE, both can access premium packs - --- Drop existing premium pack policy -DROP POLICY IF EXISTS "Premium users can view premium packs" ON public.question_packs; - --- Create new policy that checks couple premium status --- Access granted if: pack is not premium OR user is premium OR partner is premium -CREATE POLICY "Users or partners with premium can view premium packs" - ON public.question_packs FOR SELECT - USING ( - is_premium = FALSE OR - EXISTS ( - SELECT 1 FROM public.profiles p - WHERE p.id = auth.uid() - AND ( - p.is_premium = TRUE OR - EXISTS ( - SELECT 1 FROM public.profiles partner - WHERE partner.couple_id = p.couple_id - AND partner.couple_id IS NOT NULL - AND partner.id != p.id - AND partner.is_premium = TRUE - ) - ) - ) - ); - --- Update questions policy similarly -DROP POLICY IF EXISTS "Anyone can view questions in visible packs" ON public.questions; - -CREATE POLICY "Users can view questions in accessible packs" - ON public.questions FOR SELECT - USING ( - pack_id IN ( - SELECT qp.id FROM public.question_packs qp - WHERE qp.is_public = TRUE - AND ( - qp.is_premium = FALSE OR - EXISTS ( - SELECT 1 FROM public.profiles p - WHERE p.id = auth.uid() - AND ( - p.is_premium = TRUE OR - EXISTS ( - SELECT 1 FROM public.profiles partner - WHERE partner.couple_id = p.couple_id - AND partner.couple_id IS NOT NULL - AND partner.id != p.id - AND partner.is_premium = TRUE - ) - ) - ) - ) - ) - ); - --- Create a helper function to check premium access for a user --- This can be used in application code and other policies -CREATE OR REPLACE FUNCTION public.has_premium_access(check_user_id UUID) -RETURNS BOOLEAN AS $$ -DECLARE - user_premium BOOLEAN; - partner_premium BOOLEAN; - user_couple_id UUID; -BEGIN - -- Get user's premium status and couple_id - SELECT is_premium, couple_id INTO user_premium, user_couple_id - FROM public.profiles - WHERE id = check_user_id; - - -- If user is premium, return true - IF user_premium = TRUE THEN - RETURN TRUE; - END IF; - - -- If user has no couple, return false - IF user_couple_id IS NULL THEN - RETURN FALSE; - END IF; - - -- Check if partner is premium - SELECT is_premium INTO partner_premium - FROM public.profiles - WHERE couple_id = user_couple_id - AND id != check_user_id - LIMIT 1; - - RETURN COALESCE(partner_premium, FALSE); -END; -$$ LANGUAGE plpgsql SECURITY DEFINER STABLE; diff --git a/apps/supabase/migrations/20251229000003_filter_premium_packs.sql b/apps/supabase/migrations/20251229000003_filter_premium_packs.sql deleted file mode 100644 index c70f7a9..0000000 --- a/apps/supabase/migrations/20251229000003_filter_premium_packs.sql +++ /dev/null @@ -1,94 +0,0 @@ --- Fix: Filter out premium packs from get_recommended_questions when user lacks premium access --- This ensures users whose subscriptions have expired don't continue seeing premium questions - -DROP FUNCTION IF EXISTS public.get_recommended_questions(uuid); - -CREATE OR REPLACE FUNCTION public.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 INT, - partner_answered BOOLEAN -) LANGUAGE plpgsql SECURITY DEFINER AS $$ -DECLARE - v_user_id UUID; - v_couple_id UUID; - v_has_premium BOOLEAN; -BEGIN - v_user_id := auth.uid(); - - -- Get couple_id - SELECT p.couple_id INTO v_couple_id - FROM public.profiles p - WHERE p.id = v_user_id; - - IF v_couple_id IS NULL THEN - RETURN; - END IF; - - -- Check if user or partner has premium access - 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 ( - -- When targeting a specific pack, only include if accessible (not premium OR has premium access) - 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) - ) - UNION ALL - -- When browsing all enabled packs, filter out premium packs if no premium access - 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 EXISTS (SELECT 1 FROM user_config_exists) - UNION ALL - -- Default packs when no config exists (filter premium here too) - 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 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 - 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 - ); -END; -$$; diff --git a/apps/supabase/migrations/20251229000004_enforce_couple_size.sql b/apps/supabase/migrations/20251229000004_enforce_couple_size.sql deleted file mode 100644 index ebf8dcb..0000000 --- a/apps/supabase/migrations/20251229000004_enforce_couple_size.sql +++ /dev/null @@ -1,22 +0,0 @@ --- Enforce maximum 2 members per couple at the database level --- This prevents race conditions and direct API manipulation from allowing --- more than 2 people to join a single couple relationship - -CREATE OR REPLACE FUNCTION check_couple_size() -RETURNS TRIGGER AS $$ -BEGIN - -- Only check when couple_id is being set (not when clearing it) - IF NEW.couple_id IS NOT NULL THEN - -- Count existing members excluding the current user - IF (SELECT COUNT(*) FROM profiles WHERE couple_id = NEW.couple_id AND id != NEW.id) >= 2 THEN - RAISE EXCEPTION 'A couple can only have 2 members'; - END IF; - END IF; - RETURN NEW; -END; -$$ LANGUAGE plpgsql; - --- Trigger fires before insert or update of couple_id column -CREATE TRIGGER enforce_couple_size -BEFORE INSERT OR UPDATE OF couple_id ON profiles -FOR EACH ROW EXECUTE FUNCTION check_couple_size(); diff --git a/apps/supabase/migrations/20251229100000_admin_rbac.sql b/apps/supabase/migrations/20251229100000_admin_rbac.sql deleted file mode 100644 index 63fa27d..0000000 --- a/apps/supabase/migrations/20251229100000_admin_rbac.sql +++ /dev/null @@ -1,69 +0,0 @@ --- Admin Role-Based Access Control --- Creates admin_users table and helper functions for role management - --- Create admin role enum -CREATE TYPE public.admin_role AS ENUM ('pack_creator', 'super_admin'); - --- Admin users table (tracks which auth.users are admins and their role) -CREATE TABLE public.admin_users ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - user_id UUID NOT NULL UNIQUE REFERENCES auth.users(id) ON DELETE CASCADE, - role public.admin_role NOT NULL, - created_at TIMESTAMPTZ DEFAULT NOW(), - created_by UUID REFERENCES auth.users(id), - updated_at TIMESTAMPTZ DEFAULT NOW() -); - --- Index for quick lookups -CREATE INDEX idx_admin_users_user_id ON public.admin_users(user_id); -CREATE INDEX idx_admin_users_role ON public.admin_users(role); - --- Updated_at trigger -CREATE TRIGGER update_admin_users_updated_at - BEFORE UPDATE ON public.admin_users - FOR EACH ROW EXECUTE FUNCTION public.update_updated_at(); - --- Enable RLS -ALTER TABLE public.admin_users ENABLE ROW LEVEL SECURITY; - --- Policy: Allow admins to read their own record -CREATE POLICY "Admin users can read own record" - ON public.admin_users FOR SELECT - USING (auth.uid() = user_id); - --- Helper function to check if user is admin -CREATE OR REPLACE FUNCTION public.is_admin(check_user_id UUID DEFAULT auth.uid()) -RETURNS BOOLEAN AS $$ -BEGIN - RETURN EXISTS ( - SELECT 1 FROM public.admin_users WHERE user_id = check_user_id - ); -END; -$$ LANGUAGE plpgsql SECURITY DEFINER STABLE; - --- Helper function to get admin role -CREATE OR REPLACE FUNCTION public.get_admin_role(check_user_id UUID DEFAULT auth.uid()) -RETURNS public.admin_role AS $$ -DECLARE - user_role public.admin_role; -BEGIN - SELECT role INTO user_role FROM public.admin_users WHERE user_id = check_user_id; - RETURN user_role; -END; -$$ LANGUAGE plpgsql SECURITY DEFINER STABLE; - --- Helper function to check if user is super admin -CREATE OR REPLACE FUNCTION public.is_super_admin(check_user_id UUID DEFAULT auth.uid()) -RETURNS BOOLEAN AS $$ -BEGIN - RETURN EXISTS ( - SELECT 1 FROM public.admin_users - WHERE user_id = check_user_id AND role = 'super_admin' - ); -END; -$$ LANGUAGE plpgsql SECURITY DEFINER STABLE; - --- Grant execute permissions on helper functions -GRANT EXECUTE ON FUNCTION public.is_admin TO authenticated; -GRANT EXECUTE ON FUNCTION public.get_admin_role TO authenticated; -GRANT EXECUTE ON FUNCTION public.is_super_admin TO authenticated; diff --git a/apps/supabase/migrations/20251229100001_audit_log.sql b/apps/supabase/migrations/20251229100001_audit_log.sql deleted file mode 100644 index 9f687c6..0000000 --- a/apps/supabase/migrations/20251229100001_audit_log.sql +++ /dev/null @@ -1,81 +0,0 @@ --- Audit Logging System --- Creates audit_logs table and RPC function for logging admin actions - --- Create action type enum for audit logging -CREATE TYPE public.audit_action AS ENUM ('INSERT', 'UPDATE', 'DELETE'); - --- Audit log table -CREATE TABLE public.audit_logs ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - table_name TEXT NOT NULL, - record_id UUID NOT NULL, - action public.audit_action NOT NULL, - old_values JSONB, - new_values JSONB, - changed_fields TEXT[], - admin_user_id UUID NOT NULL REFERENCES auth.users(id), - admin_role public.admin_role NOT NULL, - created_at TIMESTAMPTZ DEFAULT NOW() -); - --- Indexes for querying audit logs -CREATE INDEX idx_audit_logs_table_name ON public.audit_logs(table_name); -CREATE INDEX idx_audit_logs_record_id ON public.audit_logs(record_id); -CREATE INDEX idx_audit_logs_admin_user_id ON public.audit_logs(admin_user_id); -CREATE INDEX idx_audit_logs_created_at ON public.audit_logs(created_at DESC); -CREATE INDEX idx_audit_logs_action ON public.audit_logs(action); - --- Enable RLS (super_admins only can view logs) -ALTER TABLE public.audit_logs ENABLE ROW LEVEL SECURITY; - --- Policy: Only super admins can view audit logs -CREATE POLICY "Super admins can view audit logs" - ON public.audit_logs FOR SELECT - USING (public.is_super_admin()); - --- Function to log admin actions (called from frontend via RPC) -CREATE OR REPLACE FUNCTION public.log_admin_action( - p_table_name TEXT, - p_record_id UUID, - p_action public.audit_action, - p_old_values JSONB DEFAULT NULL, - p_new_values JSONB DEFAULT NULL -) -RETURNS UUID AS $$ -DECLARE - log_id UUID; - admin_role_val public.admin_role; - changed_fields_arr TEXT[]; -BEGIN - -- Get admin role - SELECT role INTO admin_role_val FROM public.admin_users WHERE user_id = auth.uid(); - - IF admin_role_val IS NULL THEN - RAISE EXCEPTION 'User is not an admin'; - END IF; - - -- Calculate changed fields for updates - IF p_action = 'UPDATE' AND p_old_values IS NOT NULL AND p_new_values IS NOT NULL THEN - SELECT ARRAY( - SELECT key FROM jsonb_each(p_new_values) - WHERE p_old_values->key IS DISTINCT FROM p_new_values->key - ) INTO changed_fields_arr; - END IF; - - -- Insert audit log - INSERT INTO public.audit_logs ( - table_name, record_id, action, old_values, new_values, - changed_fields, admin_user_id, admin_role - ) - VALUES ( - p_table_name, p_record_id, p_action, p_old_values, p_new_values, - changed_fields_arr, auth.uid(), admin_role_val - ) - RETURNING id INTO log_id; - - RETURN log_id; -END; -$$ LANGUAGE plpgsql SECURITY DEFINER; - --- Grant execute permission on logging function -GRANT EXECUTE ON FUNCTION public.log_admin_action TO authenticated; diff --git a/apps/supabase/migrations/20251229100002_admin_rls_policies.sql b/apps/supabase/migrations/20251229100002_admin_rls_policies.sql deleted file mode 100644 index 92d58b3..0000000 --- a/apps/supabase/migrations/20251229100002_admin_rls_policies.sql +++ /dev/null @@ -1,79 +0,0 @@ --- Admin RLS Policies --- Adds policies for admin access to tables - --- Question Packs: All admins can manage (CRUD) -CREATE POLICY "Admins can view all question packs" - ON public.question_packs FOR SELECT - USING (public.is_admin()); - -CREATE POLICY "Admins can insert question packs" - ON public.question_packs FOR INSERT - WITH CHECK (public.is_admin()); - -CREATE POLICY "Admins can update question packs" - ON public.question_packs FOR UPDATE - USING (public.is_admin()); - -CREATE POLICY "Admins can delete question packs" - ON public.question_packs FOR DELETE - USING (public.is_admin()); - --- Questions: All admins can manage (CRUD) -CREATE POLICY "Admins can view all questions" - ON public.questions FOR SELECT - USING (public.is_admin()); - -CREATE POLICY "Admins can insert questions" - ON public.questions FOR INSERT - WITH CHECK (public.is_admin()); - -CREATE POLICY "Admins can update questions" - ON public.questions FOR UPDATE - USING (public.is_admin()); - -CREATE POLICY "Admins can delete questions" - ON public.questions FOR DELETE - USING (public.is_admin()); - --- Profiles: Super admins can view and update all -CREATE POLICY "Super admins can view all profiles" - ON public.profiles FOR SELECT - USING (public.is_super_admin()); - -CREATE POLICY "Super admins can update all profiles" - ON public.profiles FOR UPDATE - USING (public.is_super_admin()); - --- Couples: Super admins can view all -CREATE POLICY "Super admins can view all couples" - ON public.couples FOR SELECT - USING (public.is_super_admin()); - --- Responses: Super admins can view all -CREATE POLICY "Super admins can view all responses" - ON public.responses FOR SELECT - USING (public.is_super_admin()); - --- Matches: Super admins can view all -CREATE POLICY "Super admins can view all matches" - ON public.matches FOR SELECT - USING (public.is_super_admin()); - --- Messages: Super admins can view all -CREATE POLICY "Super admins can view all messages" - ON public.messages FOR SELECT - USING (public.is_super_admin()); - --- Subscriptions: Super admins can view all -CREATE POLICY "Super admins can view all subscriptions" - ON public.subscriptions FOR SELECT - USING (public.is_super_admin()); - --- Feedback: Super admins can view and manage all -CREATE POLICY "Super admins can view all feedback" - ON public.feedback FOR SELECT - USING (public.is_super_admin()); - -CREATE POLICY "Super admins can update all feedback" - ON public.feedback FOR UPDATE - USING (public.is_super_admin()); diff --git a/apps/supabase/migrations/20251230000001_security_hardening.sql b/apps/supabase/migrations/20251230000001_security_hardening.sql deleted file mode 100644 index 69b91d7..0000000 --- a/apps/supabase/migrations/20251230000001_security_hardening.sql +++ /dev/null @@ -1,175 +0,0 @@ --- Security Hardening Migration --- Fixes: Weak invite codes, overly permissive RLS, password settings - --- ============================================ --- 1. Strengthen invite code generation --- Use crypto-secure random with alphanumeric chars (8 chars retained for UX) --- ============================================ - --- Create a function to generate secure invite codes -CREATE OR REPLACE FUNCTION generate_secure_invite_code() -RETURNS TEXT AS $$ -DECLARE - chars TEXT := 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'; -- Removed confusing chars: I, O, 0, 1 - result TEXT := ''; - i INTEGER; - rand_bytes BYTEA; -BEGIN - -- Get 8 cryptographically secure random bytes - rand_bytes := gen_random_bytes(8); - - FOR i IN 1..8 LOOP - result := result || substr(chars, (get_byte(rand_bytes, i-1) % 32) + 1, 1); - END LOOP; - - RETURN result; -END; -$$ LANGUAGE plpgsql SECURITY DEFINER; - --- Update the default for new couples to use secure generation -ALTER TABLE public.couples -ALTER COLUMN invite_code SET DEFAULT generate_secure_invite_code(); - --- Add rate limiting tracking table for invite code attempts -CREATE TABLE IF NOT EXISTS public.invite_code_attempts ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - ip_hash TEXT NOT NULL, -- Hashed IP for privacy - user_id UUID REFERENCES auth.users(id) ON DELETE CASCADE, - attempted_at TIMESTAMPTZ DEFAULT NOW(), - was_successful BOOLEAN DEFAULT FALSE -); - --- Index for efficient rate limiting queries -CREATE INDEX IF NOT EXISTS idx_invite_attempts_user_time -ON public.invite_code_attempts(user_id, attempted_at); - --- Enable RLS on attempts table -ALTER TABLE public.invite_code_attempts ENABLE ROW LEVEL SECURITY; - --- Only allow inserts from authenticated users -CREATE POLICY "Authenticated users can log attempts" - ON public.invite_code_attempts - FOR INSERT - WITH CHECK (auth.role() = 'authenticated'); - - --- ============================================ --- 2. Fix overly permissive couples RLS policy --- ============================================ - --- Drop the overly permissive policy -DROP POLICY IF EXISTS "Users can view couple by invite code" ON public.couples; - --- Replace with a more restrictive policy --- Users can only view their own couple OR look up a couple via RPC (not direct table access) -CREATE POLICY "Users can only view their own couple" - ON public.couples FOR SELECT - USING ( - id IN (SELECT couple_id FROM public.profiles WHERE id = auth.uid()) - ); - --- Create a secure RPC function for invite code lookup --- This only returns success/failure, not couple data -CREATE OR REPLACE FUNCTION public.verify_invite_code(code TEXT) -RETURNS JSONB AS $$ -DECLARE - couple_record RECORD; - member_count INTEGER; -BEGIN - -- Normalize the code - code := UPPER(TRIM(code)); - - -- Check if code exists - SELECT id INTO couple_record - FROM public.couples - WHERE UPPER(invite_code) = code; - - IF couple_record.id IS NULL THEN - RETURN jsonb_build_object('valid', false, 'error', 'Invalid invite code'); - END IF; - - -- Check member count - SELECT COUNT(*) INTO member_count - FROM public.profiles - WHERE couple_id = couple_record.id; - - IF member_count >= 2 THEN - RETURN jsonb_build_object('valid', false, 'error', 'This couple already has two partners'); - END IF; - - RETURN jsonb_build_object('valid', true, 'couple_id', couple_record.id); -END; -$$ LANGUAGE plpgsql SECURITY DEFINER; - --- Grant execute permission to authenticated users -GRANT EXECUTE ON FUNCTION public.verify_invite_code(TEXT) TO authenticated; - - --- ============================================ --- 3. Optional: Add password policy comment --- (Password length is enforced client-side, but should also be in Supabase Auth config) --- ============================================ - -COMMENT ON SCHEMA public IS 'Password policy: Minimum 8 characters required. Configure in Supabase Dashboard > Auth > Policies'; - - --- ============================================ --- 4. Protect push tokens from being accessed by other users --- Push tokens should only be accessible via service role for notifications --- ============================================ - --- Create a secure view for partner profile access that excludes sensitive fields -CREATE OR REPLACE VIEW public.partner_profiles AS -SELECT - id, - name, - avatar_url, - is_premium, - couple_id, - created_at, - updated_at, - show_explicit_content - -- Explicitly excluding: push_token -FROM public.profiles; - --- Allow authenticated users to query the view -GRANT SELECT ON public.partner_profiles TO authenticated; - --- Add comment explaining the security rationale -COMMENT ON VIEW public.partner_profiles IS -'Secure view of profiles excluding push_token. Use this view when displaying partner information. Push tokens are only accessible via service role for sending notifications.'; - --- Create a function to safely get partner info without exposing push_token -CREATE OR REPLACE FUNCTION public.get_partner_profile() -RETURNS JSONB AS $$ -DECLARE - current_couple_id UUID; - partner_data JSONB; -BEGIN - -- Get current user's couple_id - SELECT couple_id INTO current_couple_id - FROM public.profiles - WHERE id = auth.uid(); - - IF current_couple_id IS NULL THEN - RETURN NULL; - END IF; - - -- Get partner profile without sensitive fields - SELECT jsonb_build_object( - 'id', p.id, - 'name', p.name, - 'avatar_url', p.avatar_url, - 'is_premium', p.is_premium, - 'couple_id', p.couple_id, - 'created_at', p.created_at - ) INTO partner_data - FROM public.profiles p - WHERE p.couple_id = current_couple_id - AND p.id != auth.uid(); - - RETURN partner_data; -END; -$$ LANGUAGE plpgsql SECURITY DEFINER; - -GRANT EXECUTE ON FUNCTION public.get_partner_profile() TO authenticated; diff --git a/apps/supabase/migrations/20251230000002_admin_grant_premium.sql b/apps/supabase/migrations/20251230000002_admin_grant_premium.sql deleted file mode 100644 index 93edee2..0000000 --- a/apps/supabase/migrations/20251230000002_admin_grant_premium.sql +++ /dev/null @@ -1,17 +0,0 @@ --- Allow super admins to manage subscriptions manually --- This enables the "Grant Premium" feature in the admin dashboard - --- Allow super admins to insert new manual subscriptions -CREATE POLICY "Super admins can insert subscriptions" - ON public.subscriptions FOR INSERT - WITH CHECK (public.is_super_admin()); - --- Allow super admins to update subscriptions (e.g. extending expiry) -CREATE POLICY "Super admins can update subscriptions" - ON public.subscriptions FOR UPDATE - USING (public.is_super_admin()); - --- Allow super admins to delete subscriptions (e.g. revoking access) -CREATE POLICY "Super admins can delete subscriptions" - ON public.subscriptions FOR DELETE - USING (public.is_super_admin()); diff --git a/apps/supabase/migrations/20251230110000_contextual_questions.sql b/apps/supabase/migrations/20251230110000_contextual_questions.sql deleted file mode 100644 index 8c795c5..0000000 --- a/apps/supabase/migrations/20251230110000_contextual_questions.sql +++ /dev/null @@ -1,115 +0,0 @@ - --- Ensure gender column exists on profiles (if not already present) -DO $$ -BEGIN - IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'profiles' AND column_name = 'gender') THEN - ALTER TABLE public.profiles ADD COLUMN gender TEXT CHECK (gender IN ('male', 'female', 'non-binary', 'prefer-not-to-say')); - END IF; -END $$; - --- Add allowed_couple_genders to questions -ALTER TABLE public.questions -ADD COLUMN IF NOT EXISTS allowed_couple_genders TEXT[] DEFAULT NULL; - -COMMENT ON COLUMN public.questions.allowed_couple_genders IS 'Array of allowed gender pairings (e.g., ["female+male", "female+female"]). Null means all allowed.'; - --- Update get_recommended_questions to respect gender context -DROP FUNCTION IF EXISTS public.get_recommended_questions(uuid); - -CREATE OR REPLACE FUNCTION public.get_recommended_questions(target_pack_id UUID DEFAULT NULL) -RETURNS TABLE ( - id UUID, - text TEXT, - pack_id UUID, - intensity INT, - partner_answered BOOLEAN, - allowed_couple_genders TEXT[] -) LANGUAGE plpgsql SECURITY DEFINER AS $$ -DECLARE - v_user_id UUID; - v_couple_id UUID; - v_user_gender TEXT; - v_partner_gender TEXT; - v_couple_type TEXT; -BEGIN - v_user_id := auth.uid(); - - -- Get user info including gender - 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; - - -- Get partner gender - 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; - - -- Determine couple type - IF v_user_gender IS NOT NULL AND v_partner_gender IS NOT NULL THEN - -- Sort genders to create a consistent type string - -- We assume binary genders for the primary logic as requested: male/male, male/female, female/female - 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; - - RETURN QUERY - WITH user_config_exists AS ( - SELECT 1 FROM public.couple_packs WHERE couple_id = v_couple_id LIMIT 1 - ), - active_packs AS ( - -- Case 0: Specific pack requested - SELECT target_pack_id AS pack_id - WHERE target_pack_id IS NOT NULL - - UNION ALL - - -- Case 1: No specific pack, config exists -> use enabled packs - SELECT cp.pack_id - FROM public.couple_packs cp - WHERE target_pack_id IS NULL - AND cp.couple_id = v_couple_id AND cp.enabled = TRUE - AND EXISTS (SELECT 1 FROM user_config_exists) - - UNION ALL - - -- Case 2: No specific pack, no config -> use all public packs - SELECT p.id AS pack_id - FROM public.question_packs p - WHERE target_pack_id IS NULL - AND p.is_public = TRUE - AND NOT EXISTS (SELECT 1 FROM user_config_exists) - ) - SELECT - q.id, - q.text, - 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 - FROM public.questions q - WHERE q.pack_id IN (SELECT ap.pack_id FROM active_packs ap) - AND ( - q.allowed_couple_genders IS NULL - OR v_couple_type IS NULL - OR v_couple_type = ANY(q.allowed_couple_genders) - ) - AND NOT EXISTS ( - SELECT 1 FROM public.responses r - WHERE r.question_id = q.id - AND r.user_id = v_user_id - ); -END; -$$; diff --git a/docs/schema.md b/docs/schema.md index 2cd0bf8..cb350f1 100644 --- a/docs/schema.md +++ b/docs/schema.md @@ -47,6 +47,8 @@ erDiagram text text text partner_text "for two-part questions" int intensity "1-5" + text[] allowed_couple_genders "couple composition filter" + text[] target_user_genders "individual user filter" timestamptz created_at } @@ -180,3 +182,34 @@ erDiagram 1. Couples can enable/disable packs via `couple_packs` junction table 2. `get_recommended_questions()` function filters questions by enabled packs 3. Two-part questions show `partner_text` to second responder + +### Question Targeting System + +Questions can be targeted at specific audiences using two complementary filters: + +| Field | Purpose | Level | Example | +|-------|---------|-------|---------| +| `allowed_couple_genders` | Couple composition filter | Couple | `['male+female', 'female+female']` | +| `target_user_genders` | Individual user filter | User | `['female', 'non-binary']` | + +**How targeting works:** + +1. **Couple filter** (`allowed_couple_genders`): Questions only shown to couples matching the composition (e.g., same-sex specific questions) + +2. **User filter** (`target_user_genders`): Controls who sees the question **as initiator** (first responder) + - If user's gender matches → they can see and answer first + - If user's gender doesn't match → they only see it after partner answers (via `partner_text`) + +**Example flow:** +``` +Question: + text: "Have your partner suck your tits" + partner_text: "Suck your partner's tits" + target_user_genders: ['female'] + +1. Female sees "Have your partner suck your tits" → swipes YES +2. Male sees "Suck your partner's tits" (partner_text, unlocked by her answer) +3. Both answered → Match created! +``` + +This ensures gender-appropriate phrasing while still allowing both partners to respond. diff --git a/package-lock.json b/package-lock.json index afc6df7..3865427 100644 --- a/package-lock.json +++ b/package-lock.json @@ -92,7 +92,6 @@ "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", "license": "MIT", - "peer": true, "dependencies": { "loose-envify": "^1.1.0" }, @@ -105,7 +104,6 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", "license": "MIT", - "peer": true, "dependencies": { "loose-envify": "^1.1.0", "scheduler": "^0.23.2" @@ -217,7 +215,6 @@ "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.5", @@ -957,6 +954,7 @@ "dev": true, "license": "MIT", "optional": true, + "peer": true, "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.25" @@ -3268,7 +3266,6 @@ "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", "devOptional": true, "license": "MIT", - "peer": true, "peerDependencies": { "@types/react": "^18.0.0" } @@ -3338,6 +3335,7 @@ "dev": true, "license": "MIT", "optional": true, + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -3496,7 +3494,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -3517,7 +3514,8 @@ "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", "dev": true, "license": "MIT", - "optional": true + "optional": true, + "peer": true }, "node_modules/call-bind-apply-helpers": { "version": "1.0.2", @@ -4336,7 +4334,6 @@ "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", "dev": true, "license": "MIT", - "peer": true, "bin": { "jiti": "bin/jiti.js" } @@ -4695,7 +4692,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "nanoid": "^3.3.7", "picocolors": "^1.1.1", @@ -4844,7 +4840,8 @@ "version": "0.27.0", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/react-is": { "version": "19.2.3", @@ -4858,7 +4855,6 @@ "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz", "integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==", "license": "MIT", - "peer": true, "dependencies": { "@types/use-sync-external-store": "^0.0.6", "use-sync-external-store": "^1.4.0" @@ -5018,8 +5014,7 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/redux-thunk": { "version": "3.1.0", @@ -5170,6 +5165,7 @@ "dev": true, "license": "MIT", "optional": true, + "peer": true, "dependencies": { "buffer-from": "^1.0.0", "source-map": "^0.6.0" @@ -5182,6 +5178,7 @@ "dev": true, "license": "BSD-3-Clause", "optional": true, + "peer": true, "engines": { "node": ">=0.10.0" } @@ -5320,6 +5317,7 @@ "dev": true, "license": "BSD-2-Clause", "optional": true, + "peer": true, "dependencies": { "@jridgewell/source-map": "^0.3.3", "acorn": "^8.15.0", @@ -5339,7 +5337,8 @@ "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", "dev": true, "license": "MIT", - "optional": true + "optional": true, + "peer": true }, "node_modules/thenify": { "version": "3.3.1", @@ -5411,7 +5410,6 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -5691,7 +5689,6 @@ "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.21.3", "postcss": "^8.4.43", diff --git a/packages/shared/src/types/index.ts b/packages/shared/src/types/index.ts index cafe5f1..ae9bb87 100755 --- a/packages/shared/src/types/index.ts +++ b/packages/shared/src/types/index.ts @@ -34,7 +34,10 @@ export interface Question { id: string; pack_id: string; text: string; + partner_text?: string | null; intensity: 1 | 2 | 3 | 4 | 5; + allowed_couple_genders?: string[] | null; + target_user_genders?: string[] | null; created_at: string; }