Update
No files matched your search
@@ -1,8 +1,12 @@
|
||||
{
|
||||
"mcpServers": {
|
||||
"supabase": {
|
||||
"sauci-prod": {
|
||||
"type": "http",
|
||||
"url": "https://mcp.supabase.com/mcp?project_ref=npmmakhjmbzlkcmfyegw"
|
||||
"url": "https://mcp.supabase.com/mcp?project_ref=ckjcrkjpmhqhiucifukx"
|
||||
},
|
||||
"sauci-non-prod": {
|
||||
"type": "http",
|
||||
"url": "https://mcp.supabase.com/mcp?project_ref=itbzhrvlgvdmzbnhzhyx"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -45,6 +45,20 @@ supabase functions serve # Run edge functions locally
|
||||
supabase migration new <name> # 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)
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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<ToneLevel>(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<GeneratedPack | null>(null);
|
||||
@@ -45,6 +57,9 @@ export function AiGeneratorDialog({
|
||||
const [generatedIdeas, setGeneratedIdeas] = useState<GeneratedCategoryIdea[]>([]);
|
||||
const [generatedPackIdeas, setGeneratedPackIdeas] = useState<GeneratedPackIdea[]>([]);
|
||||
|
||||
// Selection state for questions
|
||||
const [selectedQuestionIndices, setSelectedQuestionIndices] = useState<Set<number>>(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,8 +98,34 @@ 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 = () => {
|
||||
@@ -91,6 +134,7 @@ export function AiGeneratorDialog({
|
||||
setGeneratedQuestions([]);
|
||||
setGeneratedIdeas([]);
|
||||
setGeneratedPackIdeas([]);
|
||||
setSelectedQuestionIndices(new Set());
|
||||
onOpenChange(false);
|
||||
};
|
||||
|
||||
@@ -130,13 +174,38 @@ export function AiGeneratorDialog({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Explicit Toggle */}
|
||||
{(type === 'category-ideas' || type === 'category-pack-ideas' || type === 'pack' || type === 'questions') && (
|
||||
{/* Tone Selector for Questions */}
|
||||
{type === 'questions' && (
|
||||
<div className="space-y-2">
|
||||
<Label>Content Tone</Label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{TONE_LEVELS.map((t) => (
|
||||
<Button
|
||||
key={t.level}
|
||||
type="button"
|
||||
variant={tone === t.level ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => setTone(t.level as ToneLevel)}
|
||||
className={tone === t.level ? 'ring-2 ring-offset-1' : ''}
|
||||
title={t.description}
|
||||
>
|
||||
{t.level}. {t.label}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{TONE_LEVELS.find(t => t.level === tone)?.description}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Explicit Toggle for other generators */}
|
||||
{(type === 'category-ideas' || type === 'category-pack-ideas' || type === 'pack') && (
|
||||
<div className="flex items-center space-x-2">
|
||||
<Switch
|
||||
id="explicit-mode"
|
||||
checked={isExplicit}
|
||||
onCheckedChange={setIsExplicit}
|
||||
onCheckedChange={(checked) => setTone(checked ? 5 : 3)}
|
||||
/>
|
||||
<Label htmlFor="explicit-mode">Suggest Explicit Ideas</Label>
|
||||
</div>
|
||||
@@ -149,8 +218,11 @@ export function AiGeneratorDialog({
|
||||
</div>
|
||||
)}
|
||||
{context?.packName && (
|
||||
<div className="rounded-md bg-muted p-3 text-sm">
|
||||
<strong>Pack:</strong> {context.packName}
|
||||
<div className="rounded-md bg-muted p-3 text-sm space-y-1">
|
||||
<div><strong>Pack:</strong> {context.packName}</div>
|
||||
{context.packDescription && (
|
||||
<div className="text-muted-foreground text-xs">{context.packDescription}</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -185,20 +257,67 @@ export function AiGeneratorDialog({
|
||||
{/* Generated Questions Preview */}
|
||||
{generatedQuestions.length > 0 && (
|
||||
<div className="space-y-3">
|
||||
<h4 className="font-medium">Generated Questions ({generatedQuestions.length})</h4>
|
||||
<div className="flex items-center justify-between">
|
||||
<h4 className="font-medium">
|
||||
Generated Questions ({selectedQuestionIndices.size}/{generatedQuestions.length} selected)
|
||||
</h4>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={selectAllQuestions}
|
||||
disabled={selectedQuestionIndices.size === generatedQuestions.length}
|
||||
>
|
||||
<CheckSquare className="mr-1 h-3 w-3" />
|
||||
All
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={deselectAllQuestions}
|
||||
disabled={selectedQuestionIndices.size === 0}
|
||||
>
|
||||
<Square className="mr-1 h-3 w-3" />
|
||||
None
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2 max-h-64 overflow-y-auto">
|
||||
{generatedQuestions.map((q, i) => (
|
||||
<div key={i} className="rounded-lg border bg-card p-3 text-sm">
|
||||
<div
|
||||
key={i}
|
||||
className={`rounded-lg border bg-card p-3 text-sm cursor-pointer transition-colors ${
|
||||
selectedQuestionIndices.has(i)
|
||||
? 'border-primary ring-1 ring-primary/20'
|
||||
: 'opacity-60 hover:opacity-80'
|
||||
}`}
|
||||
onClick={() => toggleQuestionSelection(i)}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<Checkbox
|
||||
checked={selectedQuestionIndices.has(i)}
|
||||
onCheckedChange={() => toggleQuestionSelection(i)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="mt-0.5"
|
||||
/>
|
||||
<div className="flex-1">
|
||||
<p>{q.text}</p>
|
||||
{q.partner_text && (
|
||||
<p className="text-muted-foreground text-xs mt-1">
|
||||
Partner: {q.partner_text}
|
||||
</p>
|
||||
)}
|
||||
<span className="inline-block mt-1 text-xs px-2 py-0.5 rounded bg-muted">
|
||||
{q.intensity} - {['Light', 'Mild', 'Moderate', 'Spicy', 'Very Intense'][q.intensity - 1]}
|
||||
<span
|
||||
className={`inline-block mt-1 text-xs px-2 py-0.5 rounded text-white ${INTENSITY_LEVELS[q.intensity - 1]?.color || 'bg-muted'}`}
|
||||
title={INTENSITY_LEVELS[q.intensity - 1]?.description}
|
||||
>
|
||||
{q.intensity} - {INTENSITY_LEVELS[q.intensity - 1]?.label || 'Unknown'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
@@ -217,7 +336,10 @@ export function AiGeneratorDialog({
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-2xl">{idea.icon}</span>
|
||||
<span className="font-medium">{idea.name}</span>
|
||||
<div>
|
||||
<span className="font-medium block">{idea.name}</span>
|
||||
<span className="text-xs text-muted-foreground">{idea.description}</span>
|
||||
</div>
|
||||
</div>
|
||||
<Button size="sm" variant="ghost">Use</Button>
|
||||
</div>
|
||||
@@ -262,9 +384,9 @@ export function AiGeneratorDialog({
|
||||
Regenerate
|
||||
</Button>
|
||||
{type !== 'category-ideas' && type !== 'category-pack-ideas' && (
|
||||
<Button onClick={handleUse}>
|
||||
<Button onClick={handleUse} disabled={type === 'questions' && selectedQuestionIndices.size === 0}>
|
||||
<Check className="mr-2 h-4 w-4" />
|
||||
Use This
|
||||
{type === 'questions' ? `Use Selected (${selectedQuestionIndices.size})` : 'Use This'}
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -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 (
|
||||
<Dialog open={open} onOpenChange={handleClose}>
|
||||
<DialogContent className="max-w-4xl max-h-[90vh] flex flex-col">
|
||||
@@ -163,8 +214,8 @@ export function FixTargetsDialog({ open, onOpenChange, questions, onUpdated }: F
|
||||
/>
|
||||
</TableHead>
|
||||
<TableHead>Question</TableHead>
|
||||
<TableHead>Current</TableHead>
|
||||
<TableHead>Suggested</TableHead>
|
||||
<TableHead>Couples</TableHead>
|
||||
<TableHead>Initiator</TableHead>
|
||||
<TableHead>Reason</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
@@ -190,19 +241,28 @@ export function FixTargetsDialog({ open, onOpenChange, questions, onUpdated }: F
|
||||
</div>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="text-xs whitespace-nowrap">
|
||||
<TableCell className="text-xs">
|
||||
<div className="flex items-center gap-1">
|
||||
<Badge variant="outline" className="text-muted-foreground">
|
||||
{formatTargets(question.allowed_couple_genders)}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-xs whitespace-nowrap">
|
||||
<div className="flex items-center gap-1">
|
||||
<ArrowRight className="h-3 w-3 text-muted-foreground" />
|
||||
<Badge variant="secondary" className="bg-indigo-100 text-indigo-700 hover:bg-indigo-100">
|
||||
{formatTargets(suggestion.suggested_targets)}
|
||||
</Badge>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-xs">
|
||||
<div className="flex items-center gap-1">
|
||||
<Badge variant="outline" className="text-muted-foreground border-orange-300">
|
||||
{formatInitiator(question.target_user_genders)}
|
||||
</Badge>
|
||||
<ArrowRight className="h-3 w-3 text-muted-foreground" />
|
||||
<Badge variant="secondary" className="bg-orange-100 text-orange-700 hover:bg-orange-100">
|
||||
{formatInitiator(suggestion.suggested_initiator)}
|
||||
</Badge>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-xs text-muted-foreground">
|
||||
{suggestion.reason}
|
||||
</TableCell>
|
||||
|
||||
@@ -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<PackInfo | null>(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 (
|
||||
<nav className="flex items-center space-x-1 text-sm text-muted-foreground">
|
||||
|
||||
@@ -25,8 +25,26 @@ const getOpenAI = () => {
|
||||
});
|
||||
};
|
||||
|
||||
const getModel = () => {
|
||||
return import.meta.env.VITE_AI_MODEL || 'gpt-4o-mini';
|
||||
type ModelPurpose = 'generate' | 'fix' | 'polish';
|
||||
|
||||
const getModel = (purpose?: ModelPurpose) => {
|
||||
// Granular model selection by purpose
|
||||
// Falls back to VITE_AI_MODEL, then to default
|
||||
const defaultModel = 'gpt-4o-mini';
|
||||
const fallback = import.meta.env.VITE_AI_MODEL || defaultModel;
|
||||
|
||||
if (!purpose) return fallback;
|
||||
|
||||
switch (purpose) {
|
||||
case 'generate':
|
||||
return import.meta.env.VITE_AI_MODEL_GENERATE || fallback;
|
||||
case 'fix':
|
||||
return import.meta.env.VITE_AI_MODEL_FIX || fallback;
|
||||
case 'polish':
|
||||
return import.meta.env.VITE_AI_MODEL_POLISH || fallback;
|
||||
default:
|
||||
return fallback;
|
||||
}
|
||||
};
|
||||
|
||||
export interface GeneratedPack {
|
||||
@@ -70,7 +88,7 @@ export async function generatePack(categoryName?: string, explicit: boolean = fa
|
||||
Make it romantic, playful, and relationship-focused.`;
|
||||
|
||||
const response = await openai.chat.completions.create({
|
||||
model: getModel(),
|
||||
model: getModel('generate'),
|
||||
messages: [
|
||||
{
|
||||
role: 'system',
|
||||
@@ -88,25 +106,141 @@ export async function generatePack(categoryName?: string, explicit: boolean = fa
|
||||
return JSON.parse(content) as GeneratedPack;
|
||||
}
|
||||
|
||||
// Tone levels for content generation (controls language/explicitness)
|
||||
export const TONE_LEVELS = [
|
||||
{ level: 0, label: 'Clean', description: 'No romance or intimacy - communication, activities, fun' },
|
||||
{ level: 1, label: 'Romantic', description: 'Sweet, loving, emotional connection focus' },
|
||||
{ level: 2, label: 'Playful', description: 'Flirty, teasing, fun and lighthearted' },
|
||||
{ level: 3, label: 'Sensual', description: 'Intimate, passionate, suggestive but tasteful' },
|
||||
{ level: 4, label: 'Spicy', description: 'Bold, adventurous, mildly explicit' },
|
||||
{ level: 5, label: 'Explicit', description: 'Raw, graphic, NSFW content' },
|
||||
] as const;
|
||||
|
||||
export type ToneLevel = 0 | 1 | 2 | 3 | 4 | 5;
|
||||
|
||||
// Intensity levels for questions (controls physical intimacy level)
|
||||
// This guide helps AI consistently grade question intensity
|
||||
export const INTENSITY_GUIDE = `
|
||||
INTENSITY GRADING GUIDE - Use this to assign intensity levels consistently:
|
||||
|
||||
Level 1 - LIGHT (Emotional/Verbal):
|
||||
- Talking, sharing feelings, compliments, emotional vulnerability
|
||||
- Non-physical activities: writing letters, planning dates, sharing dreams
|
||||
- Examples: "Share your biggest dream with your partner", "Write a love note", "Tell your partner what you love most about them"
|
||||
|
||||
Level 2 - MILD (Light Physical):
|
||||
- Light physical affection: holding hands, hugging, cuddling
|
||||
- Gentle kisses, playing with hair, light touches
|
||||
- Non-sexual physical closeness: dancing together, sleeping in each other's arms
|
||||
- Examples: "Give your partner a forehead kiss", "Cuddle while watching a movie", "Hold hands during dinner"
|
||||
|
||||
Level 3 - MODERATE (Sensual/Intimate):
|
||||
- Making out, passionate kissing, intimate massage
|
||||
- Sensual touch over clothes, caressing, teasing
|
||||
- Showering together (non-sexual), skinny dipping
|
||||
- Suggestive activities that build tension without explicit sexual acts
|
||||
- Examples: "Give your partner a sensual massage", "Make out in an unexpected place", "Slow dance intimately"
|
||||
|
||||
Level 4 - SPICY (Sexual without Penetration):
|
||||
- Oral sex, manual stimulation, mutual masturbation
|
||||
- Foreplay activities, using hands/mouth on genitals
|
||||
- Using toys externally, edging, teasing to climax
|
||||
- Nakedness with sexual intent
|
||||
- Examples: "Give your partner oral", "Touch your partner until they climax", "Use a vibrator on your partner"
|
||||
|
||||
Level 5 - INTENSE (Penetration/Advanced):
|
||||
- Vaginal or anal penetration (sex, toys, fingers)
|
||||
- Kinks: bondage, role-play with power dynamics, BDSM
|
||||
- Group activities, exhibitionism, voyeurism
|
||||
- Any activity involving penetration or advanced kinks
|
||||
- Examples: "Have sex in a new location", "Try anal play", "Tie your partner up and have your way with them"
|
||||
|
||||
IMPORTANT: Intensity is about the PHYSICAL INTIMACY level, not the language used.
|
||||
A question can be intensity 5 with tasteful language (non-explicit pack) or crude language (explicit pack).
|
||||
`;
|
||||
|
||||
// Condensed version for prompts (to save tokens)
|
||||
export const INTENSITY_GUIDE_SHORT = `
|
||||
INTENSITY LEVELS:
|
||||
1 (Light): Talking, emotional sharing, compliments, non-physical activities
|
||||
2 (Mild): Holding hands, hugging, cuddling, gentle kisses, light touch
|
||||
3 (Moderate): Making out, sensual massage, passionate kissing, intimate but not sexual
|
||||
4 (Spicy): Oral sex, manual stimulation, foreplay, sexual touching without penetration
|
||||
5 (Intense): Penetration (vaginal/anal), advanced kinks, BDSM, group activities
|
||||
`;
|
||||
|
||||
// Intensity levels for UI display (consistent with AI grading)
|
||||
export const INTENSITY_LEVELS = [
|
||||
{ level: 1, label: 'Light', description: 'Talking, emotional sharing, compliments, non-physical', color: 'bg-green-500' },
|
||||
{ level: 2, label: 'Mild', description: 'Holding hands, hugging, cuddling, gentle kisses', color: 'bg-lime-500' },
|
||||
{ level: 3, label: 'Moderate', description: 'Making out, sensual massage, passionate kissing', color: 'bg-yellow-500' },
|
||||
{ level: 4, label: 'Spicy', description: 'Oral, manual stimulation, foreplay, sexual without penetration', color: 'bg-orange-500' },
|
||||
{ level: 5, label: 'Intense', description: 'Penetration, advanced kinks, BDSM, group activities', color: 'bg-red-500' },
|
||||
] as const;
|
||||
|
||||
export async function generateQuestions(
|
||||
packName: string,
|
||||
count: number = 10,
|
||||
intensity?: number,
|
||||
explicit: boolean = false
|
||||
tone: ToneLevel = 3,
|
||||
packDescription?: string
|
||||
): Promise<GeneratedQuestion[]> {
|
||||
const openai = getOpenAI();
|
||||
const intensityGuide = intensity
|
||||
? `All questions should be at intensity level ${intensity} (${['Light & Playful', 'Mildly Intimate', 'Moderately Intimate', 'Spicy & Bold', 'Very Intense'][intensity - 1]}).`
|
||||
: 'Vary the intensity levels from 1 (light/playful) to 5 (very intimate/bold) across the questions.';
|
||||
const isClean = tone === 0;
|
||||
const isExplicit = tone >= 4;
|
||||
|
||||
const explicitInstruction = explicit
|
||||
? 'CRITICAL: The user has requested EXPLICIT, NSFW content. You MUST include graphic, crude, and direct sexual terminology. Do not euphemize or be vague. This is for an adult-only app. Failure to be explicitly descriptive is a failure of the task. Be bold, spicy, and uncensored.'
|
||||
: 'Do NOT include any explicit or NSFW themes. Keep it romantic, emotional, playful, and clean.';
|
||||
const intensityInstruction = isClean
|
||||
? 'All questions should be intensity level 1 (non-physical activities).'
|
||||
: intensity
|
||||
? `All questions should be at intensity level ${intensity}.`
|
||||
: 'Vary the intensity levels from 1 to 5 across the questions for good variety.';
|
||||
|
||||
const prompt = `Generate ${count} unique questions for a couples' intimacy question pack called "${packName}".
|
||||
const toneInstructions: Record<ToneLevel, string> = {
|
||||
0: 'TONE: Completely clean and non-romantic. Focus on communication, teamwork, fun activities, personal growth, and bonding WITHOUT any romantic or sexual undertones. Think conversation starters, bucket list activities, games, challenges, and getting to know each other better. NO flirting, NO romance, NO intimacy references.',
|
||||
1: 'TONE: Romantic and sweet. Focus on emotional connection, love, and tenderness. Keep content clean and wholesome - NO sexual content whatsoever. Think date nights, compliments, and heartfelt moments.',
|
||||
2: 'TONE: Playful and flirty. Light teasing, fun activities, and cheeky suggestions. Mildly suggestive but NOTHING explicit or sexual. Think playful banter and innocent mischief.',
|
||||
3: 'TONE: Sensual and intimate. Passionate and suggestive content that implies intimacy without being graphic. Use tasteful language - "make love" rather than crude terms. Romantic but with heat. AVOID explicit sexual acts or crude terminology.',
|
||||
4: 'TONE: Spicy and bold. Adventurous sexual content with direct but not crude language. Can include kinks, fantasies, and explicit acts but described tastefully. Use terms like "oral", "bondage" etc.',
|
||||
5: 'TONE: Explicit and raw. NSFW content with graphic, direct sexual terminology. Be crude and uninhibited. Use explicit terms freely. This is adult content - do not hold back or euphemize.',
|
||||
};
|
||||
|
||||
${intensityGuide}
|
||||
${explicitInstruction}
|
||||
const toneInstruction = toneInstructions[tone];
|
||||
|
||||
// Use different examples based on tone level
|
||||
const symmetricExamples = isClean
|
||||
? 'GOOD: "Cook a new recipe together", "Play a board game", "Go on a hike", "Have a deep conversation about your goals".'
|
||||
: isExplicit
|
||||
? 'GOOD: "Have sex in a public place", "Roleplay a new scenario together", "Shower together".'
|
||||
: 'GOOD: "Cook a romantic dinner together", "Stargaze and share dreams", "Give each other massages", "Dance together at home".';
|
||||
|
||||
const asymmetricExamples = isClean
|
||||
? `text (The Doer): Active command/proposal (e.g., "Teach your partner something new", "Plan a surprise activity").
|
||||
- partner_text (The Receiver): Passive/Receiving proposal (e.g., "Learn something new from your partner", "Be surprised with an activity").
|
||||
- GOOD: "Cook your partner their favorite meal" / "Have your partner cook your favorite meal".`
|
||||
: isExplicit
|
||||
? `text (The Doer): Active command/proposal (e.g., "Tie your partner up", "Give your partner a massage").
|
||||
- partner_text (The Receiver): Passive/Receiving proposal (e.g., "Be tied up by your partner", "Receive a massage from your partner").
|
||||
- GOOD: "Spank your partner" / "Be spanked by your partner".`
|
||||
: `text (The Doer): Active command/proposal (e.g., "Write a love letter to your partner", "Plan a surprise date").
|
||||
- partner_text (The Receiver): Passive/Receiving proposal (e.g., "Receive a love letter from your partner", "Be surprised with a date").
|
||||
- GOOD: "Give your partner a massage" / "Receive a massage from your partner".`;
|
||||
|
||||
const explicitWarning = isClean
|
||||
? '\n\nCRITICAL: This is a CLEAN pack with NO romantic or sexual content. Focus ONLY on communication, activities, challenges, and bonding. NO romance, NO flirting, NO intimacy, NO physical affection beyond friendly gestures.'
|
||||
: isExplicit
|
||||
? ''
|
||||
: '\n\nCRITICAL: This is a NON-EXPLICIT pack. Do NOT include any sexual acts, crude language, or NSFW content. Keep all proposals romantic, playful, or emotionally intimate without being sexually explicit.';
|
||||
|
||||
const descriptionContext = packDescription
|
||||
? `\nPack Description: "${packDescription}"\nUse this description to guide the theme and style of questions.`
|
||||
: '';
|
||||
|
||||
const prompt = `Generate ${count} unique questions for a couples' intimacy question pack called "${packName}".${descriptionContext}
|
||||
|
||||
${INTENSITY_GUIDE_SHORT}
|
||||
|
||||
${intensityInstruction}
|
||||
${toneInstruction}${explicitWarning}
|
||||
|
||||
IMPORTANT: The app uses a swipe-based interface (Like/Dislike/Maybe).
|
||||
Cards should be "Proposals" relative to a specific action, rather than interview questions.
|
||||
@@ -116,35 +250,41 @@ For each "question", strictly decide if it is a SYMMETRIC activity (shared) or A
|
||||
1. SYMMETRIC Activities (Single Card):
|
||||
- Use for shared experiences where both partners do the same thing together.
|
||||
- Phrasing: Direct action proposal or simple "Would you like to...".
|
||||
- GOOD: "Have sex in a public place", "Roleplay a new scenario together", "Shower together".
|
||||
- ${symmetricExamples}
|
||||
- BAD: "Have you ever thought about...", "Do you think we should...", "How about we...".
|
||||
- Structure:
|
||||
- text: The action proposal (e.g., "Watch a steamy movie together").
|
||||
- text: The action proposal.
|
||||
- partner_text: null.
|
||||
|
||||
2. ASYMMETRIC Actions (Two-Part Card):
|
||||
- Use for actions where one partner does something TO the other.
|
||||
- text (The Doer): Active command/proposal (e.g., "Tie your partner up", "Give your partner a massage").
|
||||
- partner_text (The Receiver): Passive/Receiving proposal (e.g., "Be tied up by your partner", "Receive a massage from your partner").
|
||||
- GOOD: "Spank your partner" / "Be spanked by your partner".
|
||||
- ${asymmetricExamples}
|
||||
- BAD: "Would you like to spank me?" (Ambiguous who "me" is).
|
||||
|
||||
Required JSON structure:
|
||||
- text: The question/proposal text (see above).
|
||||
- partner_text: (optional) The partner-facing text for two-part cards.
|
||||
- intensity: A number 1-5 indicating how intimate/bold the question is
|
||||
- intensity: A number 1-5 based on the INTENSITY LEVELS guide above. Grade based on PHYSICAL INTIMACY, not language.
|
||||
Examples: "Share a dream" = 1, "Cuddle" = 2, "Massage" = 3, "Oral sex" = 4, "Have sex" = 5
|
||||
|
||||
Return a JSON object with a "questions" array containing these question objects.
|
||||
Mix distinct symmetric and asymmetric proposals.`;
|
||||
|
||||
const systemMessages: Record<ToneLevel, string> = {
|
||||
0: 'You are a content writer for a couples app focused on communication, activities, and bonding. Generate clean, non-romantic content like conversation starters, fun challenges, and activities. No romance or intimacy. Always respond with valid JSON only.',
|
||||
1: 'You are a romantic content writer for a couples relationship app. Generate sweet, wholesome content that helps couples connect emotionally. Always respond with valid JSON only.',
|
||||
2: 'You are a playful content writer for a couples relationship app. Generate fun, flirty content that helps couples have fun together. Always respond with valid JSON only.',
|
||||
3: 'You are a sensual content writer for a couples relationship app. Generate passionate, intimate content that helps couples explore their desires tastefully. Always respond with valid JSON only.',
|
||||
4: 'You are a bold content writer for an adult couples app. Generate spicy, adventurous content that helps couples explore kinks and fantasies. Always respond with valid JSON only.',
|
||||
5: 'You are an uninhibited content writer for an adult couples app. Generate raw, explicit, and direct content that explores kinks and desires. Do not filter for politeness. Always respond with valid JSON only.',
|
||||
};
|
||||
|
||||
const response = await openai.chat.completions.create({
|
||||
model: getModel(),
|
||||
model: getModel('generate'),
|
||||
messages: [
|
||||
{
|
||||
role: 'system',
|
||||
content: explicit
|
||||
? 'You are an uninhibited content writer for an adult couples app. Generate raw, explicit, and direct content that explores kinks and desires. Do not filter for politeness. Always respond with valid JSON only.'
|
||||
: 'You are a creative content writer for a couples relationship app. Generate engaging, tasteful questions that help couples explore their desires and deepen their connection. Always respond with valid JSON only.',
|
||||
content: systemMessages[tone],
|
||||
},
|
||||
{ role: 'user', content: prompt },
|
||||
],
|
||||
@@ -171,7 +311,7 @@ export async function generateCategory(): Promise<{ name: string; description: s
|
||||
Be creative and think of categories that help couples explore different aspects of their relationship.`;
|
||||
|
||||
const response = await openai.chat.completions.create({
|
||||
model: getModel(),
|
||||
model: getModel('generate'),
|
||||
messages: [
|
||||
{
|
||||
role: 'system',
|
||||
@@ -191,6 +331,7 @@ export async function generateCategory(): Promise<{ name: string; description: s
|
||||
|
||||
export interface GeneratedCategoryIdea {
|
||||
name: string;
|
||||
description: string;
|
||||
icon: string;
|
||||
}
|
||||
|
||||
@@ -214,12 +355,13 @@ export async function suggestCategories(
|
||||
|
||||
Return a JSON object with an "ideas" array containing 5 objects, where each object has:
|
||||
- name: Category name (1-3 words)
|
||||
- description: Brief description of what packs in this category explore (1 sentence)
|
||||
- icon: A single descriptive emoji
|
||||
|
||||
Focus on diverse topics like communications, future planning, fun, conflict resolution, intimacy, etc.`;
|
||||
|
||||
const response = await openai.chat.completions.create({
|
||||
model: getModel(),
|
||||
model: getModel('generate'),
|
||||
messages: [
|
||||
{
|
||||
role: 'system',
|
||||
@@ -271,7 +413,7 @@ export async function suggestPacks(
|
||||
Make them engaging and specific to "${categoryName}".`;
|
||||
|
||||
const response = await openai.chat.completions.create({
|
||||
model: getModel(),
|
||||
model: getModel('generate'),
|
||||
messages: [
|
||||
{
|
||||
role: 'system',
|
||||
@@ -299,7 +441,7 @@ export async function polishContent(
|
||||
|
||||
const explicitInstruction = explicit
|
||||
? 'Do not censor yourself. If the content is already explicit, keep it that way or enhance usage of bolder terms if appropriate.'
|
||||
: 'Keep it clean, romantic, and playful. Do NOT add explicit content if it was not there.';
|
||||
: 'CRITICAL: This is a NON-EXPLICIT pack. Keep it clean, romantic, and playful. Do NOT add any sexual acts, crude language, or NSFW content. If the original text contains explicit content, replace with tasteful romantic alternatives.';
|
||||
|
||||
const contextMap = {
|
||||
question: 'a question for a couples app',
|
||||
@@ -339,7 +481,7 @@ export async function polishContent(
|
||||
- polished: The improved text string`;
|
||||
|
||||
const response = await openai.chat.completions.create({
|
||||
model: getModel(),
|
||||
model: getModel('polish'),
|
||||
messages: [
|
||||
{
|
||||
role: 'system',
|
||||
@@ -361,11 +503,12 @@ export async function polishContent(
|
||||
export interface TargetAnalysis {
|
||||
id: string;
|
||||
suggested_targets: string[] | null;
|
||||
suggested_initiator: string[] | null;
|
||||
reason: string;
|
||||
}
|
||||
|
||||
export async function analyzeQuestionTargets(
|
||||
questions: { id: string; text: string; partner_text?: string | null; allowed_couple_genders: string[] | null }[]
|
||||
questions: { id: string; text: string; partner_text?: string | null; allowed_couple_genders: string[] | null; target_user_genders: string[] | null }[]
|
||||
): Promise<TargetAnalysis[]> {
|
||||
const openai = getOpenAI();
|
||||
|
||||
@@ -373,29 +516,54 @@ export async function analyzeQuestionTargets(
|
||||
id: q.id,
|
||||
text: q.text,
|
||||
partner_text: q.partner_text,
|
||||
current_targets: q.allowed_couple_genders
|
||||
current_targets: q.allowed_couple_genders,
|
||||
current_initiator: q.target_user_genders
|
||||
}));
|
||||
|
||||
const prompt = `Analyze the following questions for a couples app and suggest the appropriate "allowed_couple_genders".
|
||||
const prompt = `Analyze the following questions for a couples app and suggest:
|
||||
1. "allowed_couple_genders" (which couple types can see this question)
|
||||
2. "target_user_genders" (who should see this question FIRST as initiator - only relevant for two-part questions with partner_text)
|
||||
|
||||
Target Options: 'male+male', 'female+male', 'female+female'.
|
||||
COUPLE TARGET OPTIONS: 'male+male', 'female+male', 'female+female'
|
||||
INITIATOR OPTIONS: 'male', 'female', 'non-binary'
|
||||
|
||||
Goal: Identify anatomical or gender-role constraints.
|
||||
- Default to ALL (null or all 3) if the action is gender-neutral/universal (e.g. kissing, massage, generic sex, anal).
|
||||
- Limit ONLY if there is a clear anatomical requirement (e.g. genitalia specific) or strict gendered language (he/she/him/her).
|
||||
RULES FOR COUPLE TARGETS (allowed_couple_genders):
|
||||
- Default to ALL (null) if the action is gender-neutral/universal (e.g. kissing, massage, generic sex, anal).
|
||||
- Limit ONLY if there is a clear anatomical requirement (e.g. genitalia specific).
|
||||
|
||||
Examples:
|
||||
- "Blowjob" -> Needs penis -> Exclude F+F -> ['male+male', 'female+male']
|
||||
- "Lick pussy" -> Needs vagina -> Exclude M+M -> ['female+male', 'female+female']
|
||||
- "Titjob" -> Needs breasts -> Exclude M+M (typically) -> ['female+male', 'female+female']
|
||||
|
||||
RULES FOR INITIATOR (target_user_genders):
|
||||
- ONLY relevant for TWO-PART questions (those with partner_text set).
|
||||
- Analyze the "text" field to determine who should see this card FIRST as the initiator.
|
||||
- Consider WHO the action is TYPICALLY intended for, not just who CAN physically perform it.
|
||||
- In M+F couples, consider the typical heterosexual context of sexual acts.
|
||||
- If text implies a specific gender role, set initiator accordingly.
|
||||
- If text is truly gender-neutral AND role-neutral, leave as null.
|
||||
|
||||
Key considerations for initiator:
|
||||
1. Does the "text" describe receiving something from a penis? -> Female initiator (in M+F context)
|
||||
2. Does the "text" describe an action typically done BY someone with a penis (e.g., "my cum", "cum on you")? -> Male initiator
|
||||
3. Does the "text" describe receiving penetration? -> Consider who is typically penetrated
|
||||
4. Is the action truly symmetric with no gendered context? -> null
|
||||
|
||||
Examples:
|
||||
- text: "Swallow your partner's cum" -> Receiving cum requires partner has penis, so in M+F this is female-oriented -> initiator: ['female']
|
||||
- text: "Lick my cum off your body" -> "my cum" implies initiator produces cum (has penis) -> initiator: ['male']
|
||||
- text: "Deep throat your partner" -> Requires partner has penis, so receiver-focused -> initiator: ['female'] for M+F
|
||||
- text: "Give your partner a massage" -> Truly gender-neutral -> initiator: null
|
||||
- text: "Let her ride you" / partner_text: "Ride your partner" -> "her" implies female partner -> initiator: ['male']
|
||||
- text: "Finger your partner" / partner_text: "Be fingered" -> Receiver needs vagina -> suggested_targets: exclude M+M, initiator: null (either can finger)
|
||||
|
||||
Return a JSON object with an "analysis" array containing objects for questions where you have a recommendation.
|
||||
Include a "reason" string explaining why limits were added (or removed).
|
||||
Include ALL fields even if null. Include a "reason" string explaining your suggestions.
|
||||
|
||||
Structure:
|
||||
{
|
||||
"analysis": [
|
||||
{ "id": "...", "suggested_targets": ["male+male", "female+male"], "reason": "Requires penis" }
|
||||
{ "id": "...", "suggested_targets": ["male+male", "female+male"], "suggested_initiator": ["male"], "reason": "Text implies male initiator with female partner" }
|
||||
]
|
||||
}
|
||||
|
||||
@@ -404,7 +572,7 @@ export async function analyzeQuestionTargets(
|
||||
`;
|
||||
|
||||
const response = await openai.chat.completions.create({
|
||||
model: getModel(),
|
||||
model: getModel('fix'),
|
||||
messages: [
|
||||
{
|
||||
role: 'system',
|
||||
@@ -422,3 +590,104 @@ export async function analyzeQuestionTargets(
|
||||
const parsed = JSON.parse(content);
|
||||
return parsed.analysis || [];
|
||||
}
|
||||
|
||||
export interface TextAnalysis {
|
||||
id: string;
|
||||
suggested_text: string;
|
||||
suggested_partner_text: string | null;
|
||||
reason: string;
|
||||
}
|
||||
|
||||
export async function analyzeQuestionText(
|
||||
questions: { id: string; text: string; partner_text?: string | null }[],
|
||||
isExplicit: boolean = false
|
||||
): Promise<TextAnalysis[]> {
|
||||
const openai = getOpenAI();
|
||||
|
||||
const simplifiedQuestions = questions.map(q => ({
|
||||
id: q.id,
|
||||
text: q.text,
|
||||
partner_text: q.partner_text,
|
||||
}));
|
||||
|
||||
const toneInstruction = isExplicit
|
||||
? 'TONE: This is an EXPLICIT pack. Use raw, direct, graphic language. Do NOT sanitize or euphemize explicit content. Keep crude terms like "cum", "fuck", "cock", etc. if they appear in the original.'
|
||||
: 'TONE: This is a NON-EXPLICIT pack. CRITICAL: Do NOT include any sexual acts, crude language, or NSFW content. Keep language romantic, sensual, or playful without crude/graphic terms. If explicit sexual content appears, replace with tasteful romantic alternatives or flag for removal.';
|
||||
|
||||
// Use different examples based on explicit/non-explicit
|
||||
const symmetricExamples = isExplicit
|
||||
? 'GOOD: "Have sex in a public place", "Roleplay a new scenario together", "Shower together", "Have a threesome"'
|
||||
: 'GOOD: "Cook a romantic dinner together", "Stargaze and share your dreams", "Give each other massages", "Dance together at home"';
|
||||
|
||||
const asymmetricExamples = isExplicit
|
||||
? `text (The Doer): Active command/proposal (e.g., "Tie your partner up", "Give your partner a massage").
|
||||
- partner_text (The Receiver): Passive/Receiving proposal (e.g., "Be tied up by your partner", "Receive a massage from your partner").
|
||||
- GOOD: "Spank your partner" / "Be spanked by your partner"`
|
||||
: `text (The Doer): Active command/proposal (e.g., "Write a love letter to your partner", "Plan a surprise date for your partner").
|
||||
- partner_text (The Receiver): Passive/Receiving proposal (e.g., "Receive a love letter from your partner", "Be surprised with a date by your partner").
|
||||
- GOOD: "Give your partner a massage" / "Receive a massage from your partner"`;
|
||||
|
||||
const prompt = `Analyze the following questions for a couples app and suggest improved text that follows our style guidelines.
|
||||
|
||||
${toneInstruction}
|
||||
|
||||
The app uses a swipe-based interface (Like/Dislike/Maybe).
|
||||
Cards should be "Proposals" relative to a specific action, NOT interview questions.
|
||||
|
||||
For each question, decide if it is a SYMMETRIC activity (shared) or ASYMMETRIC action (one-way).
|
||||
|
||||
1. SYMMETRIC Activities (Single Card - partner_text should be null):
|
||||
- Use for shared experiences where both partners do the same thing together.
|
||||
- Phrasing: Direct action proposal.
|
||||
- ${symmetricExamples}
|
||||
- BAD: "Have you ever thought about...", "Do you think we should...", "How about we..."
|
||||
|
||||
2. ASYMMETRIC Actions (Two-Part Card - needs both text and partner_text):
|
||||
- Use for actions where one partner does something TO the other.
|
||||
- ${asymmetricExamples}
|
||||
- BAD: "Would you like to...?" (Ambiguous who "me" is)
|
||||
|
||||
CRITICAL RULES:
|
||||
1. PRESERVE THE CORE ACTION - Do NOT change what the question is about. Only improve phrasing.
|
||||
2. Keep text SHORT and DIRECT - no explanatory parentheticals.
|
||||
3. Remove wishy-washy language: "Would you want to...", "Have you ever...", "Do you think...", "...fantasy"
|
||||
4. Make it direct and actionable.
|
||||
5. For asymmetric cards, ensure text is initiator-focused and partner_text is receiver-focused.
|
||||
6. Use "your partner" instead of "me", "you", "him", "her" to keep it gender-neutral.
|
||||
7. If a question is ALREADY well-phrased, do NOT include it in the results.
|
||||
8. NEVER add explanatory text in parentheses - keep the proposal clean and simple.
|
||||
|
||||
Return a JSON object with a "suggestions" array containing ONLY questions that need improvement.
|
||||
Each object should have:
|
||||
- id: The question ID
|
||||
- suggested_text: The improved text (short, direct, no parentheticals)
|
||||
- suggested_partner_text: The improved partner_text (or null if symmetric)
|
||||
- reason: Brief explanation of what was improved
|
||||
|
||||
Questions:
|
||||
${JSON.stringify(simplifiedQuestions)}
|
||||
`;
|
||||
|
||||
const systemMessage = isExplicit
|
||||
? 'You are an uninhibited content editor for an adult couples app. You improve question phrasing to be direct, raw, and explicit while preserving the original intent. Do not censor or euphemize. Always respond with valid JSON only.'
|
||||
: 'You are a professional content editor for a couples app. You improve question phrasing to be direct, actionable, and engaging while preserving the original intent. Always respond with valid JSON only.';
|
||||
|
||||
const response = await openai.chat.completions.create({
|
||||
model: getModel('fix'),
|
||||
messages: [
|
||||
{
|
||||
role: 'system',
|
||||
content: systemMessage
|
||||
},
|
||||
{ role: 'user', content: prompt }
|
||||
],
|
||||
response_format: { type: 'json_object' },
|
||||
temperature: 0.5,
|
||||
});
|
||||
|
||||
const content = response.choices[0].message.content;
|
||||
if (!content) throw new Error('No content generated');
|
||||
|
||||
const parsed = JSON.parse(content);
|
||||
return parsed.suggestions || [];
|
||||
}
|
||||
@@ -14,14 +14,15 @@ import { format } from 'date-fns';
|
||||
|
||||
interface AuditLog {
|
||||
id: string;
|
||||
user_id: string;
|
||||
admin_user_id: string;
|
||||
admin_username: string | null;
|
||||
admin_role: string;
|
||||
action: string;
|
||||
table_name: string;
|
||||
record_id: string | null;
|
||||
old_data: Record<string, unknown> | null;
|
||||
new_data: Record<string, unknown> | null;
|
||||
old_values: Record<string, unknown> | null;
|
||||
new_values: Record<string, unknown> | null;
|
||||
changed_fields: string[] | null;
|
||||
created_at: string;
|
||||
user_email?: string;
|
||||
}
|
||||
|
||||
const actionColors: Record<string, string> = {
|
||||
@@ -81,9 +82,9 @@ export function AuditLogsPage() {
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-40">Time</TableHead>
|
||||
<TableHead className="w-32">Admin</TableHead>
|
||||
<TableHead className="w-24">Action</TableHead>
|
||||
<TableHead>Table</TableHead>
|
||||
<TableHead>Record ID</TableHead>
|
||||
<TableHead>Changes</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
@@ -100,6 +101,10 @@ export function AuditLogsPage() {
|
||||
<TableCell className="text-sm text-muted-foreground">
|
||||
{format(new Date(log.created_at), 'MMM d, h:mm a')}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="text-sm font-medium">{log.admin_username || 'Unknown'}</div>
|
||||
<div className="text-xs text-muted-foreground capitalize">{log.admin_role?.replace('_', ' ')}</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge className={`${actionColors[log.action] || 'bg-gray-500'} text-white`}>
|
||||
{log.action}
|
||||
@@ -108,21 +113,23 @@ export function AuditLogsPage() {
|
||||
<TableCell className="font-mono text-sm">
|
||||
{log.table_name}
|
||||
</TableCell>
|
||||
<TableCell className="font-mono text-xs text-muted-foreground">
|
||||
{log.record_id ? log.record_id.substring(0, 8) + '...' : '—'}
|
||||
</TableCell>
|
||||
<TableCell className="max-w-md">
|
||||
{log.action === 'DELETE' && log.old_data && (
|
||||
{log.action === 'DELETE' && log.old_values && (
|
||||
<code className="text-xs bg-red-50 text-red-700 px-2 py-1 rounded">
|
||||
Deleted: {JSON.stringify(log.old_data).substring(0, 100)}...
|
||||
Deleted: {JSON.stringify(log.old_values).substring(0, 100)}...
|
||||
</code>
|
||||
)}
|
||||
{log.action === 'INSERT' && log.new_data && (
|
||||
{log.action === 'INSERT' && log.new_values && (
|
||||
<code className="text-xs bg-green-50 text-green-700 px-2 py-1 rounded">
|
||||
Created: {JSON.stringify(log.new_data).substring(0, 100)}...
|
||||
Created: {JSON.stringify(log.new_values).substring(0, 100)}...
|
||||
</code>
|
||||
)}
|
||||
{log.action === 'UPDATE' && (
|
||||
{log.action === 'UPDATE' && log.changed_fields && (
|
||||
<code className="text-xs bg-blue-50 text-blue-700 px-2 py-1 rounded">
|
||||
Changed: {log.changed_fields.join(', ')}
|
||||
</code>
|
||||
)}
|
||||
{log.action === 'UPDATE' && !log.changed_fields && (
|
||||
<code className="text-xs bg-blue-50 text-blue-700 px-2 py-1 rounded">
|
||||
Updated record
|
||||
</code>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { supabase } from '@/config';
|
||||
import { auditedSupabase } from '@/hooks/useAuditedSupabase';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
@@ -153,9 +154,7 @@ export function AdminsPage() {
|
||||
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
const { error } = await supabase
|
||||
.from('admin_users')
|
||||
.insert({
|
||||
const { error } = await auditedSupabase.insert('admin_users', {
|
||||
user_id: selectedUser.id,
|
||||
role: selectedRole
|
||||
});
|
||||
@@ -182,10 +181,7 @@ export function AdminsPage() {
|
||||
}
|
||||
|
||||
try {
|
||||
const { error } = await supabase
|
||||
.from('admin_users')
|
||||
.delete()
|
||||
.eq('user_id', admin.user_id);
|
||||
const { error } = await auditedSupabase.deleteBy('admin_users', 'user_id', admin.user_id);
|
||||
|
||||
if (error) throw error;
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { supabase } from '@/config';
|
||||
import { auditedSupabase } from '@/hooks/useAuditedSupabase';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Input } from '@/components/ui/input';
|
||||
@@ -108,7 +109,7 @@ export function CategoriesPage() {
|
||||
setEditingCategory(null);
|
||||
setFormData({
|
||||
name: idea.name,
|
||||
description: '',
|
||||
description: idea.description || '',
|
||||
icon: idea.icon,
|
||||
});
|
||||
setDialogOpen(true);
|
||||
@@ -124,22 +125,17 @@ export function CategoriesPage() {
|
||||
try {
|
||||
if (editingCategory) {
|
||||
// Update
|
||||
const { error } = await supabase
|
||||
.from('categories')
|
||||
.update({
|
||||
const { error } = await auditedSupabase.update('categories', editingCategory.id, {
|
||||
name: formData.name,
|
||||
description: formData.description || null,
|
||||
icon: formData.icon || null,
|
||||
})
|
||||
.eq('id', editingCategory.id);
|
||||
});
|
||||
|
||||
if (error) throw error;
|
||||
toast.success('Category updated');
|
||||
} else {
|
||||
// Create
|
||||
const { error } = await supabase
|
||||
.from('categories')
|
||||
.insert({
|
||||
const { error } = await auditedSupabase.insert('categories', {
|
||||
name: formData.name,
|
||||
description: formData.description || null,
|
||||
icon: formData.icon || null,
|
||||
@@ -166,10 +162,7 @@ export function CategoriesPage() {
|
||||
}
|
||||
|
||||
try {
|
||||
const { error } = await supabase
|
||||
.from('categories')
|
||||
.delete()
|
||||
.eq('id', category.id);
|
||||
const { error } = await auditedSupabase.delete('categories', category.id);
|
||||
|
||||
if (error) throw error;
|
||||
toast.success('Category deleted');
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Link, useParams } from 'react-router-dom';
|
||||
import { supabase } from '@/config';
|
||||
import { auditedSupabase } from '@/hooks/useAuditedSupabase';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Input } from '@/components/ui/input';
|
||||
@@ -18,7 +19,7 @@ import {
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Plus, MessageCircle, Pencil, Trash2, Sparkles, Loader2, Crown, Eye, EyeOff } from 'lucide-react';
|
||||
import { Plus, MessageCircle, Pencil, Trash2, Sparkles, Loader2, Crown, Eye, EyeOff, Flame, Heart } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { AiGeneratorDialog } from '@/components/ai/AiGeneratorDialog';
|
||||
import { AIPolishButton } from '@/components/ai/AIPolishButton';
|
||||
@@ -36,6 +37,7 @@ interface QuestionPack {
|
||||
icon: string | null;
|
||||
is_premium: boolean;
|
||||
is_public: boolean;
|
||||
is_explicit: boolean;
|
||||
sort_order: number | null;
|
||||
category_id: string | null;
|
||||
created_at: string | null;
|
||||
@@ -59,6 +61,7 @@ export function PacksPage() {
|
||||
icon: '💕',
|
||||
is_premium: false,
|
||||
is_public: true,
|
||||
is_explicit: false,
|
||||
});
|
||||
|
||||
const fetchData = async () => {
|
||||
@@ -126,6 +129,7 @@ export function PacksPage() {
|
||||
icon: '💕',
|
||||
is_premium: false,
|
||||
is_public: true,
|
||||
is_explicit: false,
|
||||
});
|
||||
setDialogOpen(true);
|
||||
};
|
||||
@@ -138,6 +142,7 @@ export function PacksPage() {
|
||||
icon: pack.icon || '💕',
|
||||
is_premium: pack.is_premium,
|
||||
is_public: pack.is_public,
|
||||
is_explicit: pack.is_explicit,
|
||||
});
|
||||
setDialogOpen(true);
|
||||
};
|
||||
@@ -151,28 +156,25 @@ export function PacksPage() {
|
||||
setSaving(true);
|
||||
try {
|
||||
if (editingPack) {
|
||||
const { error } = await supabase
|
||||
.from('question_packs')
|
||||
.update({
|
||||
const { error } = await auditedSupabase.update('question_packs', editingPack.id, {
|
||||
name: formData.name,
|
||||
description: formData.description || null,
|
||||
icon: formData.icon || null,
|
||||
is_premium: formData.is_premium,
|
||||
is_public: formData.is_public,
|
||||
})
|
||||
.eq('id', editingPack.id);
|
||||
is_explicit: formData.is_explicit,
|
||||
});
|
||||
|
||||
if (error) throw error;
|
||||
toast.success('Pack updated');
|
||||
} else {
|
||||
const { error } = await supabase
|
||||
.from('question_packs')
|
||||
.insert({
|
||||
const { error } = await auditedSupabase.insert('question_packs', {
|
||||
name: formData.name,
|
||||
description: formData.description || null,
|
||||
icon: formData.icon || null,
|
||||
is_premium: formData.is_premium,
|
||||
is_public: formData.is_public,
|
||||
is_explicit: formData.is_explicit,
|
||||
category_id: categoryId || null,
|
||||
sort_order: packs.length,
|
||||
});
|
||||
@@ -197,10 +199,7 @@ export function PacksPage() {
|
||||
}
|
||||
|
||||
try {
|
||||
const { error } = await supabase
|
||||
.from('question_packs')
|
||||
.delete()
|
||||
.eq('id', pack.id);
|
||||
const { error } = await auditedSupabase.delete('question_packs', pack.id);
|
||||
|
||||
if (error) throw error;
|
||||
toast.success('Pack deleted');
|
||||
@@ -361,6 +360,21 @@ export function PacksPage() {
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-0.5">
|
||||
<Label>Explicit Content</Label>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Contains adult or mature content
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={formData.is_explicit}
|
||||
onCheckedChange={(checked) =>
|
||||
setFormData(d => ({ ...d, is_explicit: checked }))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
@@ -429,10 +443,26 @@ export function PacksPage() {
|
||||
Premium
|
||||
</Badge>
|
||||
)}
|
||||
{!pack.is_public && (
|
||||
{pack.is_public ? (
|
||||
<Badge variant="outline" className="border-green-500 text-green-600">
|
||||
<Eye className="h-3 w-3 mr-1" />
|
||||
Published
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant="secondary">
|
||||
<EyeOff className="h-3 w-3 mr-1" />
|
||||
Hidden
|
||||
Draft
|
||||
</Badge>
|
||||
)}
|
||||
{pack.is_explicit ? (
|
||||
<Badge variant="outline" className="border-red-500 text-red-600">
|
||||
<Flame className="h-3 w-3 mr-1" />
|
||||
Explicit
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant="outline" className="border-pink-400 text-pink-500">
|
||||
<Heart className="h-3 w-3 mr-1" />
|
||||
Safe
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -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<Question | null>(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,27 +143,24 @@ export function QuestionsPage() {
|
||||
setSaving(true);
|
||||
try {
|
||||
if (editingQuestion) {
|
||||
const { error } = await supabase
|
||||
.from('questions')
|
||||
.update({
|
||||
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,
|
||||
})
|
||||
.eq('id', editingQuestion.id);
|
||||
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({
|
||||
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;
|
||||
@@ -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,9 +198,8 @@ export function QuestionsPage() {
|
||||
// Bulk insert AI-generated questions
|
||||
const insertQuestions = async () => {
|
||||
try {
|
||||
const { error } = await supabase
|
||||
.from('questions')
|
||||
.insert(
|
||||
const { error } = await auditedSupabase.insert(
|
||||
'questions',
|
||||
generatedQuestions.map(q => ({
|
||||
pack_id: packId,
|
||||
text: q.text,
|
||||
@@ -250,9 +251,9 @@ export function QuestionsPage() {
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" onClick={() => setFixTargetsOpen(true)}>
|
||||
<ShieldCheck className="mr-2 h-4 w-4" />
|
||||
Fix Targets
|
||||
<Button variant="outline" onClick={() => setReviewOpen(true)}>
|
||||
<Sparkles className="mr-2 h-4 w-4" />
|
||||
Review Questions
|
||||
</Button>
|
||||
<Button variant="outline" onClick={() => setAiDialogOpen(true)}>
|
||||
<Sparkles className="mr-2 h-4 w-4" />
|
||||
@@ -284,6 +285,7 @@ export function QuestionsPage() {
|
||||
<AIPolishButton
|
||||
text={formData.text}
|
||||
type="question"
|
||||
explicit={pack?.is_explicit ?? false}
|
||||
onPolished={(val) => setFormData(d => ({ ...d, text: val }))}
|
||||
/>
|
||||
</div>
|
||||
@@ -308,6 +310,7 @@ export function QuestionsPage() {
|
||||
<AIPolishButton
|
||||
text={formData.partner_text}
|
||||
type="partner_text"
|
||||
explicit={pack?.is_explicit ?? false}
|
||||
onPolished={(val) => setFormData(d => ({ ...d, partner_text: val }))}
|
||||
/>
|
||||
</div>
|
||||
@@ -326,16 +329,17 @@ export function QuestionsPage() {
|
||||
<div className="space-y-2">
|
||||
<Label>Intensity Level</Label>
|
||||
<div className="flex gap-2">
|
||||
{[1, 2, 3, 4, 5].map((level) => (
|
||||
{INTENSITY_LEVELS.map((intensity) => (
|
||||
<Button
|
||||
key={level}
|
||||
key={intensity.level}
|
||||
type="button"
|
||||
variant={formData.intensity === level ? 'default' : 'outline'}
|
||||
variant={formData.intensity === intensity.level ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => setFormData(d => ({ ...d, intensity: level }))}
|
||||
className={formData.intensity === level ? intensityColors[level] : ''}
|
||||
onClick={() => setFormData(d => ({ ...d, intensity: intensity.level }))}
|
||||
className={formData.intensity === intensity.level ? intensity.color : ''}
|
||||
title={intensity.description}
|
||||
>
|
||||
{level} - {intensityLabels[level]}
|
||||
{intensity.level} - {intensity.label}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
@@ -375,6 +379,40 @@ export function QuestionsPage() {
|
||||
Select specific couple types to restrict this question. Leave all unselected to allow for everyone.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Initiator Gender (optional)</Label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{[
|
||||
{ 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 (
|
||||
<Button
|
||||
key={type.id}
|
||||
type="button"
|
||||
variant={isSelected ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setFormData(d => ({
|
||||
...d,
|
||||
target_user_genders: isSelected
|
||||
? d.target_user_genders.filter(id => id !== type.id)
|
||||
: [...d.target_user_genders, type.id]
|
||||
}));
|
||||
}}
|
||||
>
|
||||
{type.label}
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Who sees this question first? Their partner will see the Partner Text after they answer. Leave empty for anyone.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
<FixTargetsDialog
|
||||
open={fixTargetsOpen}
|
||||
onOpenChange={setFixTargetsOpen}
|
||||
<ReviewQuestionsDialog
|
||||
open={reviewOpen}
|
||||
onOpenChange={setReviewOpen}
|
||||
questions={questions}
|
||||
isExplicit={pack?.is_explicit ?? false}
|
||||
onUpdated={fetchData}
|
||||
/>
|
||||
|
||||
@@ -441,7 +480,8 @@ export function QuestionsPage() {
|
||||
<TableHead className="w-12">#</TableHead>
|
||||
<TableHead>Question</TableHead>
|
||||
<TableHead className="w-32">Partner Text</TableHead>
|
||||
<TableHead className="w-24">Targets</TableHead>
|
||||
<TableHead className="w-24">Couples</TableHead>
|
||||
<TableHead className="w-24">Initiator</TableHead>
|
||||
<TableHead className="w-24">Intensity</TableHead>
|
||||
<TableHead className="w-24">Actions</TableHead>
|
||||
</TableRow>
|
||||
@@ -479,8 +519,24 @@ export function QuestionsPage() {
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge className={`${intensityColors[question.intensity]} text-white`}>
|
||||
{intensityLabels[question.intensity]}
|
||||
{question.target_user_genders && question.target_user_genders.length > 0 ? (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{question.target_user_genders.map(g => (
|
||||
<Badge key={g} variant="outline" className="text-[10px] px-1 py-0 h-5 border-orange-500 text-orange-500">
|
||||
{g.charAt(0).toUpperCase()}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-muted-foreground text-xs">Any</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge
|
||||
className={`${getIntensity(question.intensity).color} text-white`}
|
||||
title={getIntensity(question.intensity).description}
|
||||
>
|
||||
{getIntensity(question.intensity).label}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
|
||||
@@ -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,9 +129,7 @@ export function UserDetailPage() {
|
||||
expiresAt = date.toISOString();
|
||||
}
|
||||
|
||||
const { error } = await supabase
|
||||
.from('subscriptions')
|
||||
.insert({
|
||||
const { error } = await auditedSupabase.insert('subscriptions', {
|
||||
user_id: userId,
|
||||
revenuecat_app_user_id: 'admin_grant',
|
||||
product_id: 'admin_premium',
|
||||
|
||||
@@ -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'],
|
||||
|
||||
@@ -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 }) => (
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
|
||||
<Typography component="span">{label}</Typography>
|
||||
<Tooltip title={tooltip} arrow placement="right">
|
||||
<IconButton size="small" sx={{ p: 0.25 }}>
|
||||
<HelpOutlineIcon sx={{ fontSize: 16, color: 'text.secondary' }} />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
);
|
||||
|
||||
const GenderChips = ({ record, source }: { record?: any; source: string }) => {
|
||||
const values = record?.[source];
|
||||
if (!values || values.length === 0) {
|
||||
return <Chip label="All" size="small" variant="outlined" />;
|
||||
}
|
||||
return (
|
||||
<Box sx={{ display: 'flex', gap: 0.5, flexWrap: 'wrap' }}>
|
||||
{values.map((v: string) => (
|
||||
<Chip key={v} label={v} size="small" />
|
||||
))}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export const QuestionList = () => (
|
||||
<List>
|
||||
@@ -8,31 +68,85 @@ export const QuestionList = () => (
|
||||
<TextField source="name" />
|
||||
</ReferenceField>
|
||||
<NumberField source="intensity" />
|
||||
<FunctionField
|
||||
label="Two-part"
|
||||
render={(record: any) => record?.partner_text ? 'Yes' : 'No'}
|
||||
/>
|
||||
<FunctionField
|
||||
label="Target Users"
|
||||
render={(record: any) => {
|
||||
const values = record?.target_user_genders;
|
||||
if (!values || values.length === 0) return 'All';
|
||||
return values.join(', ');
|
||||
}}
|
||||
/>
|
||||
<EditButton />
|
||||
</Datagrid>
|
||||
</List>
|
||||
);
|
||||
|
||||
export const QuestionEdit = () => (
|
||||
<Edit>
|
||||
const QuestionForm = () => (
|
||||
<SimpleForm>
|
||||
<ReferenceInput source="pack_id" reference="question_packs">
|
||||
<SelectInput optionText="name" />
|
||||
</ReferenceInput>
|
||||
<TextInput source="text" multiline fullWidth />
|
||||
|
||||
<Typography variant="subtitle2" sx={{ mt: 2, mb: 1 }}>
|
||||
Question Text
|
||||
</Typography>
|
||||
<TextInput
|
||||
source="text"
|
||||
multiline
|
||||
fullWidth
|
||||
helperText="Primary text shown to the initiator (first person to see this question)"
|
||||
/>
|
||||
<TextInput
|
||||
source="partner_text"
|
||||
multiline
|
||||
fullWidth
|
||||
helperText="Optional: Text shown to the partner after the initiator has answered. Leave blank for single-text questions."
|
||||
/>
|
||||
|
||||
<NumberInput source="intensity" min={1} max={5} />
|
||||
|
||||
<Typography variant="subtitle2" sx={{ mt: 3, mb: 1 }}>
|
||||
Targeting
|
||||
</Typography>
|
||||
|
||||
<CheckboxGroupInput
|
||||
source="target_user_genders"
|
||||
label={
|
||||
<LabelWithTooltip
|
||||
label="Show to (as initiator)"
|
||||
tooltip="The 'initiator' is the first person in the couple to see and answer a question. This filter controls which genders can be the initiator. Their partner will always see the question after the initiator answers, regardless of this setting."
|
||||
/>
|
||||
}
|
||||
choices={TARGET_USER_GENDER_CHOICES}
|
||||
helperText="Leave empty to show to all genders as initiator."
|
||||
/>
|
||||
|
||||
<CheckboxGroupInput
|
||||
source="allowed_couple_genders"
|
||||
label={
|
||||
<LabelWithTooltip
|
||||
label="Couple types"
|
||||
tooltip="Filters which couple compositions can see this question. A 'male+female' couple has one male and one female partner. This ensures questions are only shown to couples where the content is relevant to their relationship dynamic."
|
||||
/>
|
||||
}
|
||||
choices={COUPLE_GENDER_CHOICES}
|
||||
helperText="Leave empty to show to all couple types."
|
||||
/>
|
||||
</SimpleForm>
|
||||
);
|
||||
|
||||
export const QuestionEdit = () => (
|
||||
<Edit>
|
||||
<QuestionForm />
|
||||
</Edit>
|
||||
);
|
||||
|
||||
export const QuestionCreate = () => (
|
||||
<Create>
|
||||
<SimpleForm>
|
||||
<ReferenceInput source="pack_id" reference="question_packs">
|
||||
<SelectInput optionText="name" />
|
||||
</ReferenceInput>
|
||||
<TextInput source="text" multiline fullWidth />
|
||||
<NumberInput source="intensity" min={1} max={5} />
|
||||
</SimpleForm>
|
||||
<QuestionForm />
|
||||
</Create>
|
||||
);
|
||||
@@ -14,6 +14,7 @@ export default defineConfig({
|
||||
port: 3001,
|
||||
watch: {
|
||||
usePolling: true,
|
||||
interval: 100,
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -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
|
||||
@@ -1,5 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@color/iconBackground"/>
|
||||
<background android:drawable="@mipmap/ic_launcher_background"/>
|
||||
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
|
||||
<monochrome android:drawable="@mipmap/ic_launcher_monochrome"/>
|
||||
</adaptive-icon>
|
||||
@@ -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;
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
</View>
|
||||
</KeyboardAvoidingView>
|
||||
@@ -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 (
|
||||
<View style={styles.inputContainer}>
|
||||
<TouchableOpacity onPress={onPickImage} disabled={uploading} style={styles.attachButton}>
|
||||
<TouchableOpacity onPress={onTakePhoto} disabled={uploading} style={styles.attachButton}>
|
||||
{uploading ? (
|
||||
<ActivityIndicator color={colors.primary} size="small" />
|
||||
) : (
|
||||
<Ionicons name="image-outline" size={22} color={colors.textSecondary} />
|
||||
<Ionicons name="camera-outline" size={22} color={colors.textSecondary} />
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
|
||||
<TouchableOpacity onPress={onPickImage} disabled={uploading} style={styles.attachButton}>
|
||||
<Ionicons name="image-outline" size={22} color={colors.textSecondary} />
|
||||
</TouchableOpacity>
|
||||
|
||||
<View style={styles.inputFieldWrapper}>
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
|
||||
@@ -123,10 +123,13 @@ export default function PacksScreen() {
|
||||
</View>
|
||||
<View style={styles.packInfo}>
|
||||
<View style={styles.packHeader}>
|
||||
<Text style={[
|
||||
<Text
|
||||
style={[
|
||||
styles.packName,
|
||||
isPremiumLocked && styles.packNameLocked
|
||||
]}>
|
||||
]}
|
||||
numberOfLines={1}
|
||||
>
|
||||
{item.name}
|
||||
</Text>
|
||||
{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,
|
||||
|
||||
@@ -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<Question[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [activeTooltip, setActiveTooltip] = useState<TooltipType>(null);
|
||||
|
||||
const pack = packs.find((p) => p.id === id);
|
||||
|
||||
@@ -57,8 +71,33 @@ export default function PackDetailsScreen() {
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
{item.allowed_couple_genders && item.allowed_couple_genders.length > 0 ? (
|
||||
{/* Initiator indicator */}
|
||||
{item.target_user_genders && item.target_user_genders.length > 0 ? (
|
||||
<View style={styles.targetsContainer}>
|
||||
<Pressable
|
||||
style={[styles.targetBadge, styles.initiatorBadge]}
|
||||
onPress={() => setActiveTooltip('initiator')}
|
||||
>
|
||||
<Ionicons name="arrow-forward" size={12} color="#f39c12" style={{ marginRight: 4 }} />
|
||||
{item.target_user_genders.map((g, i) => (
|
||||
<Ionicons
|
||||
key={g}
|
||||
name={g === 'male' ? 'male' : g === 'female' ? 'female' : 'person'}
|
||||
size={14}
|
||||
color="#f39c12"
|
||||
style={i > 0 ? { marginLeft: 4 } : {}}
|
||||
/>
|
||||
))}
|
||||
<Ionicons name="help-circle-outline" size={14} color="#f39c12" style={{ marginLeft: 6 }} />
|
||||
</Pressable>
|
||||
</View>
|
||||
) : null}
|
||||
{/* Couple type indicator */}
|
||||
{item.allowed_couple_genders && item.allowed_couple_genders.length > 0 ? (
|
||||
<Pressable
|
||||
style={styles.targetsContainer}
|
||||
onPress={() => setActiveTooltip('coupleType')}
|
||||
>
|
||||
{item.allowed_couple_genders.map((g) => {
|
||||
const icons = [];
|
||||
if (g === 'male+male') {
|
||||
@@ -75,21 +114,26 @@ export default function PackDetailsScreen() {
|
||||
<Ionicons
|
||||
key={i}
|
||||
name={icon as any}
|
||||
size={10}
|
||||
size={14}
|
||||
color="#ccc"
|
||||
style={i > 0 ? { marginLeft: 2 } : {}}
|
||||
style={i > 0 ? { marginLeft: 4 } : {}}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
<Ionicons name="help-circle-outline" size={14} color="#ccc" style={{ marginLeft: 6 }} />
|
||||
</Pressable>
|
||||
) : (
|
||||
<View style={styles.targetsContainer}>
|
||||
<Pressable
|
||||
style={styles.targetsContainer}
|
||||
onPress={() => setActiveTooltip('coupleType')}
|
||||
>
|
||||
<View style={styles.targetBadge}>
|
||||
<Ionicons name="people" size={10} color="#ccc" />
|
||||
</View>
|
||||
<Ionicons name="people" size={14} color="#ccc" />
|
||||
</View>
|
||||
<Ionicons name="help-circle-outline" size={14} color="#ccc" style={{ marginLeft: 6 }} />
|
||||
</Pressable>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
@@ -132,6 +176,34 @@ export default function PackDetailsScreen() {
|
||||
keyExtractor={(item) => item.id}
|
||||
contentContainerStyle={styles.list}
|
||||
/>
|
||||
|
||||
{/* Tooltip Modal */}
|
||||
<Modal
|
||||
visible={activeTooltip !== null}
|
||||
transparent
|
||||
animationType="fade"
|
||||
onRequestClose={() => setActiveTooltip(null)}
|
||||
>
|
||||
<Pressable
|
||||
style={styles.modalOverlay}
|
||||
onPress={() => setActiveTooltip(null)}
|
||||
>
|
||||
<View style={styles.tooltipContainer}>
|
||||
<Text style={styles.tooltipTitle}>
|
||||
{activeTooltip && TOOLTIP_CONTENT[activeTooltip].title}
|
||||
</Text>
|
||||
<Text style={styles.tooltipDescription}>
|
||||
{activeTooltip && TOOLTIP_CONTENT[activeTooltip].description}
|
||||
</Text>
|
||||
<TouchableOpacity
|
||||
style={styles.tooltipButton}
|
||||
onPress={() => setActiveTooltip(null)}
|
||||
>
|
||||
<Text style={styles.tooltipButtonText}>Got it</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</Pressable>
|
||||
</Modal>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -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',
|
||||
},
|
||||
});
|
||||
|
Before Width: | Height: | Size: 161 KiB After Width: | Height: | Size: 688 KiB |
|
Before Width: | Height: | Size: 1.6 KiB After Width: | Height: | Size: 33 KiB |
|
Before Width: | Height: | Size: 161 KiB After Width: | Height: | Size: 688 KiB |
|
Before Width: | Height: | Size: 1.2 KiB After Width: | Height: | Size: 66 KiB |
|
Before Width: | Height: | Size: 161 KiB After Width: | Height: | Size: 688 KiB |
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -94,6 +94,64 @@ export async function clearPushToken(userId: string): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<boolean> {
|
||||
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.
|
||||
*/
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
@@ -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())
|
||||
);
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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()
|
||||
);
|
||||
@@ -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.
|
||||
@@ -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;
|
||||
@@ -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
|
||||
);
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
$$;
|
||||
@@ -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;
|
||||
$$;
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
$$;
|
||||
@@ -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())
|
||||
);
|
||||
@@ -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
|
||||
);
|
||||
@@ -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();
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
$$;
|
||||
@@ -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();
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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());
|
||||
@@ -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;
|
||||
@@ -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());
|
||||
@@ -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;
|
||||
$$;
|
||||
@@ -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.
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||