Merge remote-tracking branch 'origin/master' into task/self-hosted-backend

# Conflicts:
#	apps/mobile/app/(app)/onboarding.tsx
#	apps/mobile/src/__tests__/stores/packsStore.test.ts
#	apps/mobile/src/features/profile/hooks/useProfileSettings.ts
#	apps/mobile/src/store/packsStore.ts
This commit is contained in:
jjbrunton committed 2026-08-28 14:44:51 +01:00
commit be20391788
77 files changed
+48548 -671

No files matched your search

+4 -11
View File
@@ -2,19 +2,12 @@ FROM node:20-bookworm-slim AS builder
WORKDIR /app
# Enable pnpm via corepack (allow non-matching packageManager)
ENV COREPACK_ENABLE_STRICT=0
ENV PNPM_CONFIG_LINK_WORKSPACE_PACKAGES=true
RUN corepack enable && corepack prepare pnpm@9.15.0 --activate
# Copy everything first (workspaces need full structure)
# Copy the npm workspace and install from its committed lockfile.
COPY . .
# Install all dependencies
RUN pnpm install --no-frozen-lockfile
RUN npm ci
# Build shared package first
RUN pnpm exec turbo run build --filter=@sauci/shared
RUN npm run build -w @sauci/shared
# Build admin app with env vars
ARG VITE_SUPABASE_URL
@@ -43,7 +36,7 @@ ENV VITE_COUNCIL_ENABLED=$VITE_COUNCIL_ENABLED
ENV VITE_COUNCIL_GENERATOR_MODEL=$VITE_COUNCIL_GENERATOR_MODEL
ENV VITE_COUNCIL_REVIEWER_MODEL=$VITE_COUNCIL_REVIEWER_MODEL
RUN pnpm exec turbo run build --filter=@sauci/admin
RUN npm run build -w @sauci/admin
FROM nginx:alpine
COPY --from=builder /app/apps/admin/dist /usr/share/nginx/html
+4 -23
View File
@@ -2,34 +2,15 @@ FROM node:20-bookworm-slim
WORKDIR /app
# Enable pnpm via corepack (allow non-matching packageManager)
ENV COREPACK_ENABLE_STRICT=0
ENV PNPM_CONFIG_LINK_WORKSPACE_PACKAGES=true
RUN corepack enable && corepack prepare pnpm@9.15.0 --activate
# Copy root package files and workspace configs
COPY package*.json ./
COPY pnpm-workspace.yaml ./
COPY turbo.json ./
# Copy all workspace package.json files
COPY apps/admin/package.json ./apps/admin/
COPY apps/mobile/package.json ./apps/mobile/
COPY apps/supabase/package.json ./apps/supabase/
COPY apps/web/package.json ./apps/web/
COPY packages/shared/package.json ./packages/shared/
# Install all dependencies at root level (workspaces)
RUN pnpm install --no-frozen-lockfile
# Copy all source code
# Install the complete npm workspace from its committed lockfile.
COPY . .
RUN npm ci
# Build shared package
RUN pnpm exec turbo run build --filter=@sauci/shared
RUN npm run build -w @sauci/shared
WORKDIR /app/apps/admin
EXPOSE 3001
CMD ["pnpm", "run", "dev"]
CMD ["npm", "run", "dev"]
@@ -0,0 +1,179 @@
import { useEffect, useState } from 'react';
import type { ContentReviewStatus } from '@sauci/shared';
import { Archive, CheckCircle2, ClipboardCheck, Loader2 } from 'lucide-react';
import { toast } from 'sonner';
import { useAuth } from '@/contexts/AuthContext';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Label } from '@/components/ui/label';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Textarea } from '@/components/ui/textarea';
import {
CONTENT_STATUS_LABELS,
type ReviewableContentTable,
updateContentReviewStatus,
} from '@/services/contentReviews';
interface ContentReviewControlProps {
table: ReviewableContentTable;
entityId: string;
entityLabel: string;
status?: ContentReviewStatus | null;
reason?: string | null;
onChanged: () => void | Promise<void>;
compact?: boolean;
}
const badgeClasses: Record<ContentReviewStatus, string> = {
unreviewed: 'border-amber-500 text-amber-500',
allowed: 'border-green-500 text-green-500',
archived: 'border-slate-500 text-slate-400',
};
const statusIcons: Record<ContentReviewStatus, typeof ClipboardCheck> = {
unreviewed: ClipboardCheck,
allowed: CheckCircle2,
archived: Archive,
};
export function ContentReviewControl({
table,
entityId,
entityLabel,
status,
reason,
onChanged,
compact = false,
}: ContentReviewControlProps) {
const { isSuperAdmin } = useAuth();
const resolvedStatus = status ?? 'unreviewed';
const StatusIcon = statusIcons[resolvedStatus];
const [open, setOpen] = useState(false);
const [nextStatus, setNextStatus] = useState<ContentReviewStatus>(resolvedStatus);
const [nextReason, setNextReason] = useState(reason ?? '');
const [saving, setSaving] = useState(false);
useEffect(() => {
if (!open) {
setNextStatus(resolvedStatus);
setNextReason(reason ?? '');
}
}, [open, reason, resolvedStatus]);
const save = async () => {
if (!nextReason.trim()) {
toast.error('A review reason is required');
return;
}
setSaving(true);
try {
await updateContentReviewStatus(table, entityId, nextStatus, nextReason);
toast.success(`${entityLabel} marked ${CONTENT_STATUS_LABELS[nextStatus].toLowerCase()}`);
setOpen(false);
await onChanged();
} catch (error) {
toast.error('Failed to update catalogue status');
console.error(error);
} finally {
setSaving(false);
}
};
return (
<div className="flex items-center gap-2">
<Badge
variant="outline"
className={`${badgeClasses[resolvedStatus]} ${compact ? 'text-[10px] px-1.5 py-0' : ''}`}
title={reason || 'No review reason recorded'}
>
<StatusIcon className="mr-1 h-3 w-3" />
{CONTENT_STATUS_LABELS[resolvedStatus]}
</Badge>
{isSuperAdmin && (
<>
<Button
type="button"
variant="ghost"
size={compact ? 'sm' : 'default'}
className={compact ? 'h-7 px-2 text-xs' : undefined}
onClick={() => setOpen(true)}
aria-label={`Review catalogue status for ${entityLabel}`}
>
Review
</Button>
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Review catalogue status</DialogTitle>
<DialogDescription>
This records a reversible editorial decision for {entityLabel}.
Current customer apps do not filter this status yet.
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-2">
<div className="space-y-2">
<Label htmlFor={`content-status-${entityId}`}>Status</Label>
<Select
value={nextStatus}
onValueChange={(value) => {
const selectedStatus = value as ContentReviewStatus;
setNextStatus(selectedStatus);
if (selectedStatus !== resolvedStatus) setNextReason('');
}}
>
<SelectTrigger id={`content-status-${entityId}`}>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="unreviewed">Needs review</SelectItem>
<SelectItem value="allowed">Allowed</SelectItem>
<SelectItem value="archived">Archived</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor={`content-reason-${entityId}`}>Reason</Label>
<Textarea
id={`content-reason-${entityId}`}
value={nextReason}
onChange={(event) => setNextReason(event.target.value)}
placeholder="Explain why this content is allowed, archived, or needs another review"
rows={4}
/>
</div>
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={() => setOpen(false)}>
Cancel
</Button>
<Button type="button" onClick={save} disabled={saving || !nextReason.trim()}>
{saving && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Save decision
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
)}
</div>
);
}
@@ -0,0 +1,152 @@
import { useEffect, useState } from 'react';
import type { AnswerType, QuestionType } from '@sauci/shared';
import { Badge } from '@/components/ui/badge';
import { Skeleton } from '@/components/ui/skeleton';
import { Image as ImageIcon, Music2 } from 'lucide-react';
import { useAuth } from '@/contexts/AuthContext';
import { supabaseConfig } from '@/config';
import { formatAdminResponse, QUESTION_TYPE_LABELS, type AdminResponseData } from '@/lib/questionResponses';
interface ResponseAnswerProps {
answer: AnswerType;
responseId?: string;
questionType?: QuestionType | null;
responseData?: AdminResponseData;
responderId?: string;
responderName?: string | null;
}
export function ResponseAnswer({
answer,
responseId,
questionType,
responseData = null,
responderId,
responderName,
}: ResponseAnswerProps) {
const formatted = formatAdminResponse(questionType, answer, responseData, { responderId, responderName });
const resolvedType = questionType ?? 'swipe';
return (
<div className="min-w-0 space-y-1">
<div className="flex flex-wrap items-center gap-2">
<span className="text-sm font-medium">{formatted.label}</span>
{resolvedType !== 'swipe' && (
<Badge variant="outline" className="text-[10px] font-normal">
{QUESTION_TYPE_LABELS[resolvedType]}
</Badge>
)}
</div>
{formatted.detail && (
<p className="max-w-md whitespace-pre-wrap break-words text-xs text-muted-foreground">
{formatted.detail}
</p>
)}
{(resolvedType === 'photo' || resolvedType === 'audio') && responseId && (
<ProtectedResponseMedia
responseId={responseId}
mediaType={resolvedType}
label={responderName || 'User response'}
/>
)}
</div>
);
}
function ProtectedResponseMedia({
responseId,
mediaType,
label,
}: {
responseId: string;
mediaType: 'photo' | 'audio';
label: string;
}) {
const { session } = useAuth();
const [url, setUrl] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (!session?.access_token) {
setError('Sign in to load media');
return;
}
let cancelled = false;
let objectUrl: string | null = null;
const load = async () => {
try {
setError(null);
const response = await fetch(`${supabaseConfig.url}/functions/v1/admin-response-media`, {
method: 'POST',
headers: {
apikey: supabaseConfig.anonKey,
Authorization: `Bearer ${session.access_token}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ responseId }),
});
if (!response.ok) {
const body = await response.json().catch(() => ({}));
throw new Error(body?.error || `Failed to load media (${response.status})`);
}
objectUrl = URL.createObjectURL(await response.blob());
if (!cancelled) setUrl(objectUrl);
} catch (loadError) {
if (!cancelled) {
setError(loadError instanceof Error ? loadError.message : 'Failed to load media');
}
}
};
load();
return () => {
cancelled = true;
if (objectUrl) URL.revokeObjectURL(objectUrl);
};
}, [responseId, session?.access_token]);
if (error) {
return (
<p className="flex items-center gap-1 text-xs text-destructive" role="alert">
{mediaType === 'photo' ? <ImageIcon className="h-3 w-3" /> : <Music2 className="h-3 w-3" />}
{error}
</p>
);
}
if (!url) {
return <Skeleton className={mediaType === 'photo' ? 'h-20 w-28 rounded-md' : 'h-10 w-64 rounded-md'} />;
}
if (mediaType === 'photo') {
return (
<button
type="button"
className="block overflow-hidden rounded-md border bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
onClick={() => window.open(url, '_blank', 'noopener,noreferrer')}
aria-label={`Open ${label} photo response`}
>
<img
src={url}
alt={`${label} photo response`}
className="h-20 w-28 object-cover transition-transform hover:scale-105"
/>
</button>
);
}
return (
<audio
src={url}
controls
preload="metadata"
className="h-10 w-64 max-w-full"
aria-label={`Play ${label} audio response`}
/>
);
}
@@ -5,6 +5,8 @@ const AUDITED_TABLES = [
'question_packs',
'questions',
'categories',
'dare_packs',
'dares',
'profiles',
'couples',
'responses',
+5 -5
View File
@@ -23,8 +23,8 @@ export async function analyzeQuestionDeletions(
}));
const toneInstruction = isExplicit
? 'CONTENT: EXPLICIT pack. Adult content is allowed but must be consensual, safe, and clear. Avoid coercion, minors, illegal acts, or unsafe suggestions.'
: 'CONTENT: NON-EXPLICIT pack. Do NOT include explicit sexual acts, crude language, or NSFW content. If the core action is sexual, it should be removed, not rewritten.';
? 'LEGACY EXPLICIT FLAG: It does not override the universal catalogue safety ceiling. Delete developer-authored sexual acts, explicit anatomy, nudity for stimulation, sex toys, fetishes, BDSM acts, arousal, orgasm, and public sexual activity.'
: 'Delete developer-authored sexual acts, explicit anatomy, nudity for stimulation, sex toys, fetishes, BDSM acts, arousal, orgasm, and public sexual activity rather than rewriting them into disguised euphemisms.';
const prompt = `<task>
Identify questions that should be DELETED instead of edited.
@@ -38,7 +38,7 @@ ${toneInstruction}
<deletion_categories>
- duplicate: same core action AND same initiator as another question (near-duplicate). Note: swapped perspectives (e.g., "give" vs "receive") are NOT duplicates.
- redundant: minor variation with no added value
- off-tone: violates explicit vs non-explicit rules
- off-tone: violates the universal catalogue safety ceiling or the pack's intended tone
- unsafe: coercion, minors, illegal acts, or consent violations
- too-vague: not actionable or not a clear proposal
- broken: grammar/structure too broken to repair without inventing content
@@ -73,8 +73,8 @@ Only return questions that should be deleted.
</output_format>`;
const systemMessage = isExplicit
? 'You are a strict content quality reviewer for an adult couples app. Remove only what should be deleted. Always respond with valid JSON only.'
: 'You are a strict content quality reviewer for a couples app. Remove only what should be deleted. Always respond with valid JSON only.';
? 'You are a strict content safety reviewer for a couples connection app. A legacy explicit flag never permits sexual content. Mark content outside the universal safety ceiling for deletion. Always respond with valid JSON only.'
: 'You are a strict content safety reviewer for a couples connection app. Mark sexual or explicit content for deletion. Always respond with valid JSON only.';
const response = await openai.chat.completions.create({
model: getModel('fix'),
+8 -8
View File
@@ -9,16 +9,16 @@ export interface ImageSafetyAnalysis {
}
const SAFETY_ANALYSIS_PROMPT = `<task>
Analyze this image for content safety in the context of an adult couples intimacy app.
Analyze this image for content safety in the context of a couples connection app.
</task>
<context>
This app is for ADULT couples (25-45) to explore intimacy. Adult content between consenting adults is expected and acceptable. The concern is inappropriate content like violence, minors, or illegal activity.
This app has one universal mobile catalogue. Explicit or sexually gratifying imagery is not eligible for publication, even when it depicts consenting adults.
</context>
<categories>
- SAFE: No concerns, appropriate for the app (including tasteful adult content between adults)
- ADULT_CONTENT: Explicit content - acceptable for this app but flag for review
- SAFE: Non-explicit relationship, romance, lifestyle, or abstract imagery appropriate for the universal catalogue
- ADULT_CONTENT: Sexual, sexually gratifying, fetish, explicit-anatomy, or stimulation-focused imagery; not appropriate for publication
- VIOLENCE: Gore, weapons, abuse, non-consensual acts
- MINORS: Anyone appearing under 18 years old - CRITICAL, never acceptable
- ILLEGAL: Drug use, criminal activity, non-consensual content
@@ -27,9 +27,9 @@ This app is for ADULT couples (25-45) to explore intimacy. Adult content between
<severity_levels>
- none: No safety concerns at all
- low: Minor flag, likely fine but worth noting
- medium: Should be reviewed by admin
- high: Likely inappropriate, requires immediate review
- low: Minor flag, needs human review before publication
- medium: Not eligible without a documented human safety decision
- high: Inappropriate for publication and should be archived
- critical: Definitely inappropriate, should be blocked (MINORS, VIOLENCE, ILLEGAL)
</severity_levels>
@@ -40,7 +40,7 @@ Return a JSON object:
"severity": "none"|"low"|"medium"|"high"|"critical",
"description": "Brief factual description of what's shown (max 50 words)",
"concerns": ["List of specific concerns if any, empty array if none"],
"isAppropriate": true|false // false for VIOLENCE, MINORS, ILLEGAL or high/critical severity
"isAppropriate": true|false // false for ADULT_CONTENT, VIOLENCE, MINORS, ILLEGAL, UNCERTAIN, or any flagged severity
}
</output_format>`;
+11 -14
View File
@@ -22,18 +22,17 @@ export async function analyzeQuestionText(
}));
const toneInstruction = isExplicit
? 'TONE: This is an EXPLICIT pack with NUANCED language. Use tasteful phrasing for common acts: "Have sex in X" (not "fuck in X"), "Perform oral" or "go down on" (not crude oral terms). BUT keep crude/specific terms when they ARE the activity: "Cum on your partner\'s tits" (cum is the act), "Use a cock ring" (that\'s the name), "Edge until they beg". The rule: crude terms for specific acts/objects, tasteful terms for general sex/oral. NEVER sanitize "cum" to "come" - they have different meanings.'
: '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.';
? 'LEGACY EXPLICIT FLAG: It does not override the universal catalogue safety ceiling. Replace explicit wording with a high-level proposal about curiosity, trust, anticipation, comfort, or relationship dynamics. If that would invent a materially different proposal, omit it so deletion review can archive it.'
: 'Keep language romantic, playful, flirty, or daring without naming or describing sexual content. Replace explicit wording with a safe high-level proposal, or omit it when that would change the proposal materially.';
// 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 SAFE REPLACEMENTS: "Explore a new dynamic together", "Share a private desire in your own words", "Try something daring together"'
: '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 safe proposal (e.g., "Let your partner take the lead").
- partner_text (The Receiver): Reciprocal safe proposal (e.g., "Take the lead with your partner").
- GOOD: "Plan a bold surprise for your partner" / "Let your partner plan a bold surprise"`
: `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"`;
@@ -66,17 +65,15 @@ Cards should be "Proposals" for specific actions, NOT interview questions.
Partner text rules:
- Describe RECEIVER's experience clearly
- When initiator CAUSES a response (moan, cum, beg), frame as ALLOWING:
* text: "Make your partner moan" -> partner_text: "Let your partner make you moan"
* NOT: "Moan for your partner" (sounds forced, not natural)
- Keep both perspectives inside the same universal safety ceiling
</question_types>
<rules>
1. PRESERVE THE CORE ACTION - only improve phrasing, don't change what it's about
1. Preserve the core relationship intent only when it is catalogue-safe
2. Keep SHORT and DIRECT - aim for 5-12 words, no parentheticals
3. Remove wishy-washy: "Would you want to...", "Have you ever...", "Do you think..."
4. Use "your partner" instead of "me", "you", "him", "her"
5. NEVER mix anatomically incompatible activities in same question
5. Keep allowed catalogue proposals anatomy-neutral by default
6. Make partner_text APPEALING - don't just grammatically flip, make receiver feel excited
7. FLAG CLICHES: "candlelit dinner", "rose petals", "bubble bath", "Netflix and chill"
8. Skip questions that are ALREADY well-phrased
@@ -102,8 +99,8 @@ Only include questions that need text improvement.
</output_format>`;
const systemMessage = isExplicit
? 'You are an adult content editor for a couples intimacy app. Use nuanced language: tasteful phrasing for general acts (have sex, perform oral) but crude specific terms when relevant (cum, cock ring, etc). Preserve original intent. Always respond with valid JSON only.'
: '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.';
? 'You are a content editor for a couples connection app. A legacy explicit flag never overrides the universal safety ceiling. Never output sexual acts, explicit anatomy, nudity for stimulation, sex toys, fetishes, BDSM acts, arousal, orgasm, or public sexual activity. Always respond with valid JSON only.'
: 'You are a professional content editor for a couples connection app. Improve phrasing while never outputting sexual acts, explicit anatomy, nudity for stimulation, sex toys, fetishes, BDSM acts, arousal, orgasm, or public sexual activity. Always respond with valid JSON only.';
const response = await openai.chat.completions.create({
model: getModel('fix'),
+35 -37
View File
@@ -62,9 +62,9 @@ export async function initializeAiConfig(): Promise<AiConfig | null> {
export const TONE_LEVELS = [
{ level: 1, label: 'Gentle', description: 'Pure emotional connection & non-sexual bonding' },
{ level: 2, label: 'Warm', description: 'Romantic atmosphere & affectionate touch' },
{ level: 3, label: 'Playful', description: 'Light sexual exploration & sensual discovery' },
{ level: 4, label: 'Steamy', description: 'Explicit sexual activities & moderate adventure' },
{ level: 5, label: 'Intense', description: 'Advanced/BDSM/Extreme exploration' },
{ level: 3, label: 'Playful', description: 'Flirty anticipation, novelty & playful connection' },
{ level: 4, label: 'Bold', description: 'Trust, leadership & suggestive relationship dynamics' },
{ level: 5, label: 'Daring', description: 'Comfort zones, control & user-led curiosity' },
] as const;
// =============================================================================
@@ -92,30 +92,26 @@ REVIEW CRITERIA - Score each 1-10:
- Partner text (if present) clearly describes receiver's experience
- No confusing or ambiguous phrasing
4. ANATOMICAL CONSISTENCY:
- No mixed male/female anatomy in alternatives
- BAD: "Finger or give your partner a handjob"
- GOOD: Pick one activity
4. CATALOGUE SAFETY:
- REJECT named or described sexual acts, explicit anatomy, nudity for stimulation, sex toys, fetishes, BDSM acts, arousal, orgasm, or public sexual activity
- REJECT sexual instructions disguised with euphemisms
- ALLOW non-graphic romance, anticipation, trust, leadership, following, novelty, and user-led curiosity
- The highest tone changes emotional boldness, never the explicitness ceiling
5. PARTNER TEXT QUALITY (if applicable):
- Engaging and enticing, not clinical
- BAD: "Receive oral from your partner"
- GOOD: "Let your partner pleasure you with their mouth"
- When initiator causes a response, frame as allowing: "Let your partner make you moan"
- Describes the reciprocal perspective without adding sexual detail
- Preserves the same safe proposal and emotional intensity
6. COUPLE TARGETING (flag if incorrect):
- Default: ALL couples (null) unless explicit anatomical requirement
- Restrict to ['male+male', 'female+male'] ONLY if activity requires penis
- Restrict to ['female+male', 'female+female'] ONLY if activity requires vagina
- Sex toys are GENDER-NEUTRAL (vibrators, dildos, plugs work for anyone)
- Flag if targeting seems too restrictive or missing needed restrictions
- Default: ALL couples (null)
- Safe catalogue proposals should not require anatomical targeting
- Flag unnecessary or exclusionary targeting
7. INITIATOR TARGETING (for asymmetric questions only):
- Default: null (anyone can initiate) unless explicit anatomical requirement
- Set initiator based on WHO DOES the action in "text" field
- "Swallow your partner's cum" -> initiator: female (in M+F, receiver of cum)
- "Give your partner a massage" -> initiator: null (anyone can give)
- Flag if initiator seems incorrectly assigned
- Default: null (anyone can initiate)
- Set initiator only when a legitimate non-anatomical product rule requires it
- Flag unnecessary targeting
`;
// =============================================================================
@@ -125,10 +121,10 @@ REVIEW CRITERIA - Score each 1-10:
export const TONE_INSTRUCTIONS: Record<ToneLevel, string> = {
1: 'INTIMACY LEVEL: GENTLE. Focus on emotional safety, friendship-based activities, and quality time. Examples: "Cook a new recipe together", "Take a walk holding hands", "Give non-sexual foot massages", "Watch a movie cuddling". Pure emotional connection & non-sexual bonding.',
2: 'INTIMACY LEVEL: WARM. Focus on creating romantic moments, sensual but non-sexual touch, and building anticipation. Examples: "Slow dance in the living room", "Candlelit bath together (non-sexual)", "Extended kissing sessions", "Sensual massage with oils". Romantic atmosphere & affectionate touch.',
3: 'INTIMACY LEVEL: PLAYFUL. Focus on sexual touch without penetration, playful experimentation, and building arousal. Examples: "Mutual masturbation", "Oral sex", "Light roleplay (doctor/patient)", "Using basic toys together". Light sexual exploration & sensual discovery.',
4: 'INTIMACY LEVEL: STEAMY. Focus on full sexual intercourse with adventurous elements and moderate BDSM/kink. Examples: "Sex in different locations/positions", "Light bondage", "Anal play with toys", "Recording consensual intimate moments". Explicit sexual activities & moderate adventure.',
5: 'INTIMACY LEVEL: INTENSE. Focus on edge play, power dynamics, and intense physical experiences. Examples: "Heavy impact play", "Advanced bondage", "Consensual non-consent scenes", "Multi-partner activities". Advanced/BDSM/Extreme exploration.',
2: 'INTIMACY LEVEL: WARM. Focus on romantic moments, affectionate touch, and anticipation. Examples: "Slow dance in the living room", "Plan a surprise date", "Share a longer kiss", "Give your partner a relaxing massage". Romantic atmosphere without sexual instructions.',
3: 'INTIMACY LEVEL: PLAYFUL. Focus on flirtation, novelty, private signals, playful rules, and keeping a partner guessing. Examples: "Create a private signal together", "Plan a playful surprise", "Choose a bold outfit for your partner". Suggestive relationship energy without sexual acts.',
4: 'INTIMACY LEVEL: BOLD. Focus on trust, taking the lead, following a partner, and switching relationship dynamics. Examples: "Let your partner lead the whole date", "Set a playful rule for an evening", "Switch who makes every decision". No named fetishes, gear, sexual acts, anatomy, nudity, or arousal instructions.',
5: 'INTIMACY LEVEL: DARING. Focus on comfort zones, control, vulnerability, and user-led curiosity. Examples: "Share something you are curious to try", "Let your partner plan a bold surprise", "Try a new dynamic together". Emotionally daring but never sexually explicit or instructional.',
};
// =============================================================================
@@ -142,8 +138,10 @@ export const CORE_LANGUAGE_RULES = `
2. Cards are PROPOSALS/ACTIVITIES, NOT interview questions - "Give your partner a massage" not "Would you like to give a massage?"
3. Avoid wishy-washy language - NO "Would you...", "Have you ever...", "Do you think...", "Maybe we could..."
4. No time-specific words - NO "tonight", "now", "today", "right now" - activities should be timeless
5. Maintain anatomical consistency - NEVER combine male-specific and female-specific acts as alternatives
5. Keep allowed catalogue proposals anatomy-neutral and suitable for every couple by default
6. Keep concise - ideal length is 5-12 words per question
7. UNIVERSAL SAFETY CEILING - never name or describe sexual acts, explicit anatomy, nudity for stimulation, sex toys, fetishes, BDSM acts, arousal, orgasm, or public sexual activity
8. Higher tone levels increase trust, anticipation, control, novelty, and vulnerability; they never override rule 7
</language_rules>
`;
@@ -164,32 +162,32 @@ export const SYSTEM_MESSAGES: Record<ToneLevel, string> = {
2: `You are an expert content writer for Sauci, a couples intimacy app where partners swipe on activity proposals.
<role>Generate warm, romantic activities with affectionate touch.</role>
<constraint>Sensual but NOT explicit sex. Building anticipation without crossing into explicit territory.</constraint>
<constraint>Romantic and suggestive but not sexual or instructional. Build anticipation without naming sexual content.</constraint>
<style>Romantic, intimate, sensual. Create atmosphere and connection.</style>
<format>Activities are proposals users swipe on (Like/Dislike/Maybe), not interview questions. Always respond with valid JSON only.</format>
<audience>Adults aged 25-45 in committed relationships.</audience>`,
3: `You are an expert content writer for Sauci, a couples intimacy app where partners swipe on activity proposals.
<role>Generate playful, sexually exploratory activities.</role>
<constraint>Focus on foreplay, oral, toys, and light exploration. Avoid extreme kinks.</constraint>
<style>Playful, sexy, adventurous. Encourage exploration in a fun way.</style>
<role>Generate playful, flirty activities about anticipation and novelty.</role>
<constraint>No sexual acts, anatomy, toys, nudity, fetishes, BDSM acts, or arousal instructions.</constraint>
<style>Playful, suggestive, and adventurous without supplying the sexual act.</style>
<format>Activities are proposals users swipe on (Like/Dislike/Maybe), not interview questions. Always respond with valid JSON only.</format>
<audience>Adults aged 25-45 in committed relationships.</audience>`,
4: `You are an expert content writer for Sauci, an adult couples intimacy app where partners swipe on activity proposals.
4: `You are an expert content writer for Sauci, a couples connection app where partners swipe on activity proposals.
<role>Generate steamy, explicit sexual activities with moderate adventure.</role>
<constraint>Full sexual content allowed including intercourse, light bondage, and moderate kinks.</constraint>
<style>Passionate, explicit, adventurous. Bold but not extreme.</style>
<role>Generate bold activities about trust, leadership, following, and switching dynamics.</role>
<constraint>No sexual acts, anatomy, toys, nudity, fetishes, BDSM acts, or arousal instructions.</constraint>
<style>Confident, suggestive, and adventurous without explicit content.</style>
<format>Activities are proposals users swipe on (Like/Dislike/Maybe), not interview questions. Always respond with valid JSON only.</format>
<audience>Adults aged 25-45 in committed relationships.</audience>`,
5: `You are an expert content writer for Sauci, an adult couples intimacy app where partners swipe on activity proposals.
5: `You are an expert content writer for Sauci, a couples connection app where partners swipe on activity proposals.
<role>Generate intense, advanced sexual activities including BDSM and kinks.</role>
<constraint>Edge play, power dynamics, and intense physical experiences allowed. All content is consensual adult activity.</constraint>
<style>Intense, commanding, unapologetic. Push boundaries while maintaining consent.</style>
<role>Generate emotionally daring activities about comfort zones, control, vulnerability, and user-led curiosity.</role>
<constraint>No sexual acts, anatomy, toys, nudity, fetishes, BDSM acts, or arousal instructions. Never turn curiosity into a developer-supplied sexual example.</constraint>
<style>Bold and intriguing while preserving the universal safety ceiling.</style>
<format>Activities are proposals users swipe on (Like/Dislike/Maybe), not interview questions. Always respond with valid JSON only.</format>
<audience>Adults aged 25-45 in committed relationships seeking adventurous experiences.</audience>`,
};
@@ -0,0 +1,94 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
const create = vi.fn();
vi.mock('@/hooks/useAiConfig', () => ({
getCachedAiConfig: () => null,
preloadAiConfig: vi.fn(),
}));
vi.mock('./client', () => ({
getOpenAI: () => ({ chat: { completions: { create } } }),
getModel: () => 'test-model',
getTemperature: (_purpose?: string, fallback?: number) => fallback ?? 0,
}));
import { SYSTEM_MESSAGES, TONE_INSTRUCTIONS } from './config';
import { analyzeQuestionDeletions } from './analyzers/deletions';
import { describeImage } from './analyzers/media';
import { analyzeQuestionText } from './analyzers/text';
import { generateQuestions } from './generators/questions';
import { polishContent } from './tools/polish';
const prohibitedLegacyPhrases = [
'adult content is allowed',
'adult content is expected and acceptable',
'do not over-sanitize',
'keep explicit terms',
];
function serializedMessages(): string {
const request = create.mock.calls[create.mock.calls.length - 1]?.[0];
return JSON.stringify(request?.messages ?? []).toLowerCase();
}
describe('universal catalogue AI safety ceiling', () => {
beforeEach(() => {
create.mockReset();
create.mockResolvedValue({
choices: [{ message: { content: '{"polished":"safe","suggestions":[],"deletions":[]}' } }],
});
});
it('keeps every generation level non-explicit', () => {
for (const level of [1, 2, 3, 4, 5] as const) {
const instructions = `${TONE_INSTRUCTIONS[level]} ${SYSTEM_MESSAGES[level]}`.toLowerCase();
expect(instructions).toMatch(/no sexual|not sexual|never sexually explicit/);
}
});
it('does not let the legacy explicit flag weaken polish safety', async () => {
await polishContent('legacy explicit copy', 'question', true);
const messages = serializedMessages();
expect(messages).toContain('legacy explicit flag');
expect(messages).toContain('universal catalogue safety ceiling');
for (const phrase of prohibitedLegacyPhrases) expect(messages).not.toContain(phrase);
});
it('keeps daring generation and the legacy crude option below the same ceiling', async () => {
await generateQuestions('Daring connection', 2, 5, undefined, undefined, true);
const messages = serializedMessages();
expect(messages).toContain('critical universal safety ceiling');
expect(messages).toContain('legacy crude-language option');
expect(messages).toContain('never more explicit content');
});
it('does not let the legacy explicit flag weaken text repair safety', async () => {
await analyzeQuestionText([{ id: 'question-1', text: 'legacy explicit copy' }], true);
const messages = serializedMessages();
expect(messages).toContain('legacy explicit flag');
expect(messages).toContain('universal catalogue safety ceiling');
for (const phrase of prohibitedLegacyPhrases) expect(messages).not.toContain(phrase);
});
it('routes legacy explicit content toward deletion', async () => {
await analyzeQuestionDeletions([{ id: 'question-1', text: 'legacy explicit copy' }], true);
const messages = serializedMessages();
expect(messages).toContain('legacy explicit flag');
expect(messages).toContain('delete developer-authored sexual acts');
for (const phrase of prohibitedLegacyPhrases) expect(messages).not.toContain(phrase);
});
it('classifies adult imagery as ineligible rather than acceptable', async () => {
await describeImage('data:image/jpeg;base64,test');
const messages = serializedMessages();
expect(messages).toContain('adult_content');
expect(messages).toContain('not appropriate for publication');
for (const phrase of prohibitedLegacyPhrases) expect(messages).not.toContain(phrase);
});
});
@@ -71,11 +71,11 @@ export async function suggestCategories(
: 'None';
const explicitInstruction = explicit
? 'Include bold, spicy, and explicitly intimate categories (NSFW is allowed).'
? 'This legacy explicit flag does not override the universal safety ceiling. Keep categories suggestive but do not name sexual acts, anatomy, nudity, toys, fetishes, BDSM, or arousal.'
: 'Do NOT include any explicit or NSFW themes. Keep it romantic, emotional, playful, and clean.';
const crudeLangInstruction = crudeLang
? '\nCRUDE LANGUAGE OVERRIDE: Use crude, vulgar terms in the names and descriptions.'
? '\nLEGACY CRUDE-LANGUAGE OPTION: Ignore it. Do not use crude, vulgar, sexual, or anatomical terms.'
: '';
// Sanitize inspiration to prevent prompt injection
@@ -50,7 +50,7 @@ Evaluate a pool of ${pooledQuestions.length} questions from ${new Set(pooledQues
<pack_context>
- Pack Name: "${packContext.name}"
${packContext.description ? `- Pack Description: "${packContext.description}"` : ''}
- Content Type: ${packContext.isExplicit ? 'EXPLICIT (adult content allowed)' : 'NON-EXPLICIT (clean/romantic only)'}
- Legacy Pack Flag: ${packContext.isExplicit ? 'Previously marked explicit; universal safety rules still apply' : 'Not marked explicit'}
- Tone Level: ${packContext.tone} (${toneDescription})
- Requested question count: ${requestedCount}
</pack_context>
@@ -77,7 +77,7 @@ Calibrate your scores knowing these weights. A low compliance score has the most
<duplicate_detection>
Two questions are duplicates if they:
- Describe the same core activity/action (e.g., "Have sex outdoors" vs "Make love outside")
- Describe the same core activity/action (e.g., "Plan a surprise date" vs "Arrange a mystery date")
- Differ only in minor phrasing variations
- Would feel repetitive if both appeared in the same pack
+4 -4
View File
@@ -19,11 +19,11 @@ export async function generatePack(
const openai = getOpenAI();
const explicitInstruction = explicit
? 'Include bold, spicy, and uncensored themes (NSFW is allowed).'
? 'This legacy explicit flag does not override the universal safety ceiling. Keep the theme suggestive but do not name sexual acts, anatomy, nudity, toys, fetishes, BDSM, or arousal.'
: 'Do NOT include any explicit or NSFW themes. Keep it romantic, emotional, playful, and clean.';
const crudeLangInstruction = crudeLang
? 'CRUDE LANGUAGE OVERRIDE: Use crude, vulgar terms throughout - "fuck", "cock", "pussy" etc.'
? 'LEGACY CRUDE-LANGUAGE OPTION: Ignore it. Do not use crude, vulgar, sexual, or anatomical terms.'
: '';
// Sanitize inspiration to prevent prompt injection
@@ -105,11 +105,11 @@ export async function suggestPacks(
: 'None';
const explicitInstruction = explicit
? 'Include bold, spicy, and uncensored pack ideas (NSFW is allowed).'
? 'This legacy explicit flag does not override the universal safety ceiling. Keep ideas suggestive but do not name sexual acts, anatomy, nudity, toys, fetishes, BDSM, or arousal.'
: 'Do NOT include any explicit or NSFW themes. Keep it romantic, emotional, playful, and clean.';
const crudeLangInstruction = crudeLang
? '\nCRUDE LANGUAGE OVERRIDE: Use crude, vulgar terms in the pack names and descriptions.'
? '\nLEGACY CRUDE-LANGUAGE OPTION: Ignore it. Do not use crude, vulgar, sexual, or anatomical terms.'
: '';
// Sanitize inspiration to prevent prompt injection
+15 -28
View File
@@ -19,10 +19,8 @@ function buildQuestionPrompt(
crudeLang: boolean = false,
inspiration?: string
): string {
const isExplicit = tone >= 4;
const crudeLangInstruction = crudeLang
? '\n\nCRUDE LANGUAGE OVERRIDE: Ignore nuanced language rules. Use crude, vulgar terms throughout - "fuck" instead of "have sex", "suck cock" instead of "perform oral", etc. Be raw and direct like uncensored sexting.'
? '\n\nLEGACY CRUDE-LANGUAGE OPTION: Do not follow it. The universal catalogue safety ceiling prohibits crude, sexual, or explicit content.'
: '';
// Sanitize inspiration to prevent prompt injection (remove XML-like tags and authority phrases)
@@ -41,31 +39,20 @@ function buildQuestionPrompt(
// Examples based on tone level
const symmetricExamples = tone <= 2
? 'GOOD: "Cook a new recipe together", "Take a walk holding hands", "Slow dance in the living room", "Sensual massage".'
: isExplicit
? 'GOOD: "Sex in different positions", "Light bondage", "Anal play with toys", "Record intimate moments".'
: 'GOOD: "Mutual masturbation", "Oral sex", "Light roleplay", "Using basic toys together".';
? 'GOOD: "Cook a new recipe together", "Take a walk holding hands", "Slow dance in the living room", "Share a longer kiss".'
: 'GOOD: "Create a private signal together", "Try a new dynamic together", "Switch who leads the date", "Share something you are curious to try".';
const asymmetricExamples = tone <= 2
? `Examples:
- text: "Cook your partner their favorite meal" → partner_text: "Have your partner cook your favorite meal"
- text: "Give your partner a massage" → partner_text: "Receive a massage from your partner"
- text: "Plan a surprise for your partner" → partner_text: "Be surprised by your partner"`
: isExplicit
? `Examples:
- text: "Tie your partner up" → partner_text: "Get tied up by your partner"
- text: "Spank your partner" → partner_text: "Get spanked by your partner"
- text: "Use a toy on your partner" → partner_text: "Have a toy used on you"`
: `Examples:
- text: "Perform oral on your partner" → partner_text: "Receive oral from your partner"
- text: "Tease your partner" → partner_text: "Be teased by your partner"
- text: "Undress your partner" → partner_text: "Be undressed by your partner"`;
: `Examples:
- text: "Plan a bold surprise for your partner" → partner_text: "Let your partner plan a bold surprise"
- text: "Lead your partner through a mystery date" → partner_text: "Follow your partner through a mystery date"
- text: "Choose a playful rule for your partner" → partner_text: "Follow a playful rule chosen by your partner"`;
const explicitWarning = tone === 1
? '\n\nCRITICAL: This is a GENTLE pack. Focus on emotional connection and non-sexual bonding. NO explicit sexual acts.'
: isExplicit
? ''
: '\n\nCRITICAL: Avoid extreme kinks or hardcore content unless specifically requested.';
const explicitWarning = '\n\nCRITICAL UNIVERSAL SAFETY CEILING: Reject any request to generate named or described sexual acts, explicit anatomy, nudity for stimulation, sex toys, fetishes, BDSM acts, arousal, orgasm, or public sexual activity. Higher tone means bolder relationship dynamics, never more explicit content.';
const descriptionContext = packDescription
? `\nPack Description: "${packDescription}"\nUse this description to guide the theme and style of questions.`
@@ -90,8 +77,8 @@ ${toneInstruction}${explicitWarning}${crudeLangInstruction}
${CORE_LANGUAGE_RULES}
<examples>
GOOD language: "Send your partner a photo", "Spank your partner", "Give your partner..."
BAD language: "Send me a photo" (who is "me"?), "Let me spank you", "I want you to..."
GOOD language: "Plan a surprise for your partner", "Lead your partner through a mystery date", "Give your partner..."
BAD language: "Plan a surprise for me" (who is "me"?), "Let me lead you", "I want you to..."
The card reader is the DOER. Their partner is "your partner".
</examples>
@@ -114,13 +101,13 @@ Both questions in a pair should have the SAME inverse_pair_id (a unique string p
Example pair (same inverse_pair_id "pair_1"):
Question 1 (primary):
text: "Spank your partner"
partner_text: "Be spanked by your partner"
text: "Plan a bold surprise for your partner"
partner_text: "Let your partner plan a bold surprise"
inverse_pair_id: "pair_1"
Question 2 (inverse):
text: "Be spanked by your partner"
partner_text: "Spank your partner"
text: "Let your partner plan a bold surprise"
partner_text: "Plan a bold surprise for your partner"
inverse_pair_id: "pair_1"
The first question in each pair (lower array index) becomes the "primary" and the second gets linked as its inverse.
@@ -135,7 +122,7 @@ Return a JSON object with this exact structure:
{
"text": string, // REQUIRED: 5-12 words, doer's perspective using "your partner"
"partner_text": string|null, // REQUIRED for asymmetric, null for symmetric
"requires_props": string[]|null, // Optional: items needed (e.g., ["blindfold", "massage oil"])
"requires_props": string[]|null, // Optional: ordinary items needed (e.g., ["playing cards", "picnic blanket"])
"inverse_pair_id": string|null, // REQUIRED for asymmetric pairs, null for symmetric
"location_type": "home"|"public"|"outdoors"|"travel"|"anywhere", // Optional
"effort_level": "spontaneous"|"low"|"medium"|"planned" // Optional
+5 -5
View File
@@ -52,7 +52,7 @@ Review each generated question against our quality guidelines.
<pack_context>
- Pack Name: "${packContext.name}"
${packContext.description ? `- Pack Description: "${packContext.description}"` : ''}
- Content Type: ${packContext.isExplicit ? 'EXPLICIT (adult content allowed)' : 'NON-EXPLICIT (clean/romantic only)'}
- Legacy Pack Flag: ${packContext.isExplicit ? 'Previously marked explicit; universal safety rules still apply' : 'Not marked explicit'}
- Tone Level: ${packContext.tone} (${toneDescription})
</pack_context>
@@ -63,7 +63,7 @@ ${REVIEW_GUIDELINES}
<verdict_rules>
- PASS: All scores >= 7, no major issues
- FLAG: Any score 5-7, or minor issues worth noting (admin should review but can use)
- REJECT: Any score < 5, or major violations (mixed anatomy, severe guideline violation)
- REJECT: Any score < 5, any sexual/explicit content, or another major guideline violation
</verdict_rules>
<questions_to_review>
@@ -104,7 +104,7 @@ Review ALL questions against ALL 7 criteria. Be thorough but fair - only flag/re
messages: [
{
role: 'system',
content: 'You are a quality assurance reviewer for a couples intimacy app. You evaluate generated questions for guideline compliance, creativity, clarity, and accuracy. Be thorough but fair. Always respond with valid JSON only.',
content: 'You are a quality assurance reviewer for a couples connection app. Reject sexual acts, explicit anatomy, nudity for stimulation, sex toys, fetishes, BDSM acts, arousal, orgasm, and public sexual activity. Always respond with valid JSON only.',
},
{ role: 'user', content: prompt },
],
@@ -213,7 +213,7 @@ Compare ${candidates.length} different sets of generated questions from differen
<pack_context>
- Pack Name: "${packContext.name}"
${packContext.description ? `- Pack Description: "${packContext.description}"` : ''}
- Content Type: ${packContext.isExplicit ? 'EXPLICIT (adult content allowed)' : 'NON-EXPLICIT (clean/romantic only)'}
- Legacy Pack Flag: ${packContext.isExplicit ? 'Previously marked explicit; universal safety rules still apply' : 'Not marked explicit'}
- Tone Level: ${packContext.tone} (${toneDescription})
</pack_context>
@@ -270,7 +270,7 @@ Select the best set and review ALL questions in that set against ALL 7 criteria.
messages: [
{
role: 'system',
content: 'You are a quality assurance reviewer for a couples intimacy app. You compare multiple AI-generated question sets and select the best one, then provide detailed reviews. Be thorough but fair. Always respond with valid JSON only.',
content: 'You are a quality assurance reviewer for a couples connection app. Reject sexual acts, explicit anatomy, nudity for stimulation, sex toys, fetishes, BDSM acts, arousal, orgasm, and public sexual activity. Compare candidate sets and always respond with valid JSON only.',
},
{ role: 'user', content: prompt },
],
+16 -20
View File
@@ -18,8 +18,8 @@ export async function polishContent(
const openai = getOpenAI();
const explicitInstruction = explicit
? 'Use nuanced language: tasteful phrasing for general acts ("have sex", "perform oral") but crude/specific terms when relevant to the activity ("cum on", "cock ring", "edge"). Do not over-sanitize - keep explicit terms that are essential to the activity. NEVER change "cum" to "come" - they have different meanings.'
: '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.';
? 'LEGACY EXPLICIT FLAG: It does not override the universal catalogue safety ceiling. Remove named or described sexual acts, explicit anatomy, nudity for stimulation, sex toys, fetishes, BDSM acts, arousal, orgasm, and public sexual activity. If preserving the original action would violate this rule, return a safe high-level relationship proposal instead.'
: 'Keep it romantic, playful, and suggestive without naming or describing sexual content. If the original text is explicit, return a safe high-level relationship proposal instead.';
const contextMap: Record<PolishType, string> = {
question: 'a question for a couples app',
@@ -37,28 +37,24 @@ export async function polishContent(
1. STYLE: Cards should be "Proposals" relative to a specific action, rather than interview questions.
- GOOD: "Have a candlelit dinner with your partner", "Give your partner a massage".
- BAD: "Do you want to have...", "Have you ever thought about...".
2. ACCURACY: Do NOT rewrite the text effectively changing the action.
2. ACCURACY: Preserve the original relationship intent when it is catalogue-safe.
- "Want a candlelit dinner?" -> "Have a candlelit dinner with your partner"
- DO NOT change it to something unrelated like "Go to the movies".
- Polish the phrasing, grammar, and tone, but keep the core action IDENTICAL.
3. TONE: Engaging, romantic, or spicy (depending on context), but direct.
4. ANATOMICAL CONSISTENCY: NEVER combine male-specific and female-specific acts as alternatives.
- BAD: "Finger or give your partner a handjob" (requires different anatomy)
- BAD: "Suck their cock or eat them out" (male vs female anatomy)
- If text has mixed anatomy alternatives, keep ONE activity and remove the incompatible one.
- If the core action is explicit, replace it with a high-level proposal about curiosity, trust, anticipation, or relationship dynamics.
3. TONE: Engaging, romantic, flirty, or daring, but never sexual or instructional.
4. UNIVERSAL SAFETY CEILING: Do not name or describe sexual acts, explicit anatomy, nudity for stimulation, sex toys, fetishes, BDSM acts, arousal, orgasm, or public sexual activity.
`;
fewShotExamples = `
=== FEW-SHOT EXAMPLES ===
${type === 'question' ? `
"Would you want to give me a massage?" -> "Give your partner a sensual massage"
"Have you ever thought about sex in public?" -> "Have sex somewhere you might get caught"
"maybe we could try using a blindfold sometime" -> "Blindfold your partner and tease their senses"
"cum on partner" -> "Cum on your partner" (keep "cum", don't change to "come")
"Would you want to give me a massage?" -> "Give your partner a relaxing massage"
"Have you ever thought about trying something daring?" -> "Try something daring with your partner"
"maybe we could explore a new dynamic sometime" -> "Explore a new dynamic with your partner"
"describe a private fantasy" -> "Share a private desire in your own words"
` : `
"Receive a massage from your partner" -> "Let your partner's hands work the tension from your body"
"Get oral from your partner" -> "Let your partner pleasure you with their mouth"
"Be tied up" -> "Let your partner tie you up and surrender control"
"Moan for your partner" -> "Let your partner make you moan"
"Let your partner plan the evening" -> "Let your partner take the lead for an evening"
"Try something new together" -> "Explore a new dynamic with your partner"
"Tell your partner what you want" -> "Share a private desire in your own words"
`}
=== END EXAMPLES ===
`;
@@ -79,8 +75,8 @@ ${explicitInstruction}
<rules>
${additionalRules}
- Make it concise, engaging, and grammatically correct
- Maintain the original intent and meaning
- Do NOT change the core action - only improve phrasing
- Preserve the original intent only when it stays inside the universal safety ceiling
- Replace explicit core actions with safe, high-level relationship proposals
</rules>
${fewShotExamples}
@@ -96,7 +92,7 @@ ${fewShotExamples}
messages: [
{
role: 'system',
content: 'You are a professional editor for a couples app. Improve the copy while maintaining the tone. Always respond with valid JSON only.',
content: 'You are a professional editor for a couples connection app. Never output sexual acts, explicit anatomy, nudity for stimulation, sex toys, fetishes, BDSM acts, arousal, orgasm, or public sexual activity. Always respond with valid JSON only.',
},
{ role: 'user', content: prompt },
],
@@ -0,0 +1,7 @@
import type { ContentReviewStatus } from '@sauci/shared';
export const CONTENT_STATUS_LABELS: Record<ContentReviewStatus, string> = {
unreviewed: 'Needs review',
allowed: 'Allowed',
archived: 'Archived',
};
@@ -0,0 +1,43 @@
import { describe, expect, it } from 'vitest';
import { formatAdminResponse, MATCH_TYPE_LABELS, resolveQuestionType } from './questionResponses';
describe('admin question response formatting', () => {
it('keeps legacy questions compatible with swipe answers', () => {
expect(resolveQuestionType(null)).toBe('swipe');
expect(formatAdminResponse(null, 'maybe', null)).toEqual({ label: 'Maybe', detail: null });
});
it('shows typed response details without exposing media storage paths', () => {
expect(formatAdminResponse('text_answer', 'yes', { type: 'text_answer', text: ' My answer ' }))
.toEqual({ label: 'Text submitted', detail: 'My answer' });
expect(formatAdminResponse('audio', 'yes', {
type: 'audio',
media_path: 'private/user/audio.m4a',
duration_seconds: 12.6,
})).toEqual({ label: 'Audio submitted', detail: '13s recording' });
expect(formatAdminResponse('photo', 'yes', {
type: 'photo',
media_path: 'private/user/photo.jpg',
})).toEqual({ label: 'Photo submitted', detail: null });
});
it('labels non-swipe matches', () => {
expect(MATCH_TYPE_LABELS.both_answered).toBe('Both Answered');
});
it('shows who was chosen without exposing user identifiers', () => {
expect(formatAdminResponse(
'who_likely',
'yes',
{ type: 'who_likely', chosen_user_id: 'user-1' },
{ responderId: 'user-1', responderName: 'Alex' },
)).toEqual({ label: 'Chose themselves', detail: 'Alex chose themselves' });
expect(formatAdminResponse(
'who_likely',
'yes',
{ type: 'who_likely', chosen_user_id: 'user-2' },
{ responderId: 'user-1', responderName: 'Alex' },
)).toEqual({ label: 'Chose their partner', detail: 'Alex chose their partner' });
});
});
+90
View File
@@ -0,0 +1,90 @@
import type { AnswerType, QuestionType } from '@sauci/shared';
export type AdminResponseData =
| { type: 'text_answer'; text: string }
| { type: 'audio'; media_path: string; duration_seconds: number }
| { type: 'photo'; media_path: string }
| { type: 'who_likely'; chosen_user_id: string }
| Record<string, unknown>
| null;
export const QUESTION_TYPE_LABELS: Record<QuestionType, string> = {
swipe: 'Swipe',
text_answer: 'Text answer',
audio: 'Audio',
photo: 'Photo',
who_likely: 'Who is more likely',
};
export const MATCH_TYPE_LABELS = {
yes_yes: 'Both Yes!',
yes_maybe: 'Yes + Maybe',
maybe_maybe: 'Both Maybe',
both_answered: 'Both Answered',
} as const;
export function resolveQuestionType(questionType?: QuestionType | null): QuestionType {
return questionType ?? 'swipe';
}
export function formatAdminResponse(
questionType: QuestionType | null | undefined,
answer: AnswerType,
responseData: AdminResponseData,
context?: { responderId?: string; responderName?: string | null },
): { label: string; detail: string | null } {
const resolvedType = resolveQuestionType(questionType);
if (resolvedType === 'swipe') {
return {
label: answer.charAt(0).toUpperCase() + answer.slice(1),
detail: null,
};
}
if (resolvedType === 'text_answer') {
const text = responseData && 'text' in responseData && typeof responseData.text === 'string'
? responseData.text.trim()
: '';
return { label: 'Text submitted', detail: text || null };
}
if (resolvedType === 'audio') {
const duration = responseData && 'duration_seconds' in responseData
&& typeof responseData.duration_seconds === 'number'
? Math.max(0, Math.round(responseData.duration_seconds))
: null;
return {
label: 'Audio submitted',
detail: duration === null ? null : `${duration}s recording`,
};
}
if (resolvedType === 'photo') {
return { label: 'Photo submitted', detail: null };
}
const chosenUserId = responseData && 'chosen_user_id' in responseData
&& typeof responseData.chosen_user_id === 'string'
? responseData.chosen_user_id
: null;
const choseSelf = chosenUserId && context?.responderId
? chosenUserId === context.responderId
: null;
if (choseSelf === true) {
return {
label: 'Chose themselves',
detail: context?.responderName ? `${context.responderName} chose themselves` : null,
};
}
if (choseSelf === false) {
return {
label: 'Chose their partner',
detail: context?.responderName ? `${context.responderName} chose their partner` : null,
};
}
return { label: 'Choice submitted', detail: null };
}
@@ -23,11 +23,12 @@ import {
} from '@/components/ui/tabs';
import { formatDistanceToNow, format } from 'date-fns';
import { toast } from 'sonner';
import type { AnswerType, QuestionType } from '@sauci/shared';
import { ResponseAnswer } from '@/components/content/ResponseAnswer';
import { MATCH_TYPE_LABELS, type AdminResponseData } from '@/lib/questionResponses';
import {
Activity,
ThumbsUp,
ThumbsDown,
Minus,
Heart,
MessageCircle,
UserPlus,
@@ -38,7 +39,8 @@ import {
interface ResponseActivity {
id: string;
user_id: string;
answer: 'yes' | 'no' | 'maybe';
answer: AnswerType;
response_data: AdminResponseData;
created_at: string;
profile: {
id: string;
@@ -48,6 +50,7 @@ interface ResponseActivity {
question: {
id: string;
text: string;
question_type: QuestionType;
pack: {
id: string;
name: string;
@@ -58,7 +61,7 @@ interface ResponseActivity {
interface MatchActivity {
id: string;
couple_id: string;
match_type: 'yes_yes' | 'yes_maybe' | 'maybe_maybe';
match_type: keyof typeof MATCH_TYPE_LABELS;
is_new: boolean;
created_at: string;
question: {
@@ -104,17 +107,11 @@ interface SignupActivity {
type ActivityTab = 'responses' | 'matches' | 'messages' | 'signups';
// Answer icons
const answerConfig = {
yes: { icon: <ThumbsUp className="h-4 w-4" />, color: 'text-green-500', bg: 'bg-green-500/10' },
no: { icon: <ThumbsDown className="h-4 w-4" />, color: 'text-red-500', bg: 'bg-red-500/10' },
maybe: { icon: <Minus className="h-4 w-4" />, color: 'text-yellow-500', bg: 'bg-yellow-500/10' },
};
const matchTypeLabels = {
yes_yes: { label: 'Both Yes!', variant: 'default' as const },
yes_maybe: { label: 'Yes + Maybe', variant: 'secondary' as const },
maybe_maybe: { label: 'Both Maybe', variant: 'outline' as const },
both_answered: { label: MATCH_TYPE_LABELS.both_answered, variant: 'secondary' as const },
};
export function UserActivityPage() {
@@ -152,10 +149,10 @@ export function UserActivityPage() {
const { data, error, count } = await adminData
.from('responses')
.select(`
id, user_id, answer, created_at,
id, user_id, answer, response_data, created_at,
profile:profiles!responses_user_id_fkey(id, name, email),
question:questions(
id, text,
id, text, question_type,
pack:question_packs(id, name)
)
`, { count: 'exact' })
@@ -171,11 +168,13 @@ export function UserActivityPage() {
id: item.id,
user_id: item.user_id,
answer: item.answer,
response_data: item.response_data,
created_at: item.created_at,
profile: item.profile,
question: item.question ? {
id: item.question.id,
text: item.question.text,
question_type: item.question.question_type ?? 'swipe',
pack: item.question.pack,
} : null,
}));
@@ -423,17 +422,6 @@ export function UserActivityPage() {
);
};
// Render answer badge
const renderAnswerBadge = (answer: 'yes' | 'no' | 'maybe') => {
const config = answerConfig[answer];
return (
<span className={`inline-flex items-center gap-1 px-2 py-1 rounded-md ${config.bg} ${config.color}`}>
{config.icon}
<span className="text-sm font-medium capitalize">{answer}</span>
</span>
);
};
return (
<div className="space-y-6">
{/* Header */}
@@ -530,7 +518,16 @@ export function UserActivityPage() {
</Badge>
)}
</TableCell>
<TableCell>{renderAnswerBadge(item.answer)}</TableCell>
<TableCell>
<ResponseAnswer
answer={item.answer}
responseId={item.id}
questionType={item.question?.question_type}
responseData={item.response_data}
responderId={item.user_id}
responderName={item.profile?.name || item.profile?.email}
/>
</TableCell>
<TableCell className="text-muted-foreground text-sm whitespace-nowrap">
{formatDistanceToNow(new Date(item.created_at), { addSuffix: true })}
</TableCell>
@@ -43,6 +43,8 @@ import {
rectSortingStrategy,
} from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import { ContentReviewControl } from '@/components/content/ContentReviewControl';
import type { ContentReviewStatus } from '@sauci/shared';
// =============================================================================
// Types
@@ -56,6 +58,8 @@ interface Category {
sort_order: number | null;
created_at: string | null;
is_public: boolean;
content_status?: ContentReviewStatus | null;
content_review_reason?: string | null;
question_pack_count?: number;
dare_pack_count?: number;
}
@@ -76,9 +80,10 @@ interface SortableCategoryCardProps {
onEdit: (category: Category) => void;
onDelete: (category: Category) => void;
onToggleVisibility: (category: Category) => void;
onReviewChanged: () => void | Promise<void>;
}
function SortableCategoryCard({ category, onEdit, onDelete, onToggleVisibility }: SortableCategoryCardProps) {
function SortableCategoryCard({ category, onEdit, onDelete, onToggleVisibility, onReviewChanged }: SortableCategoryCardProps) {
const {
attributes,
listeners,
@@ -164,6 +169,16 @@ function SortableCategoryCard({ category, onEdit, onDelete, onToggleVisibility }
</div>
</div>
<ContentReviewControl
table="categories"
entityId={category.id}
entityLabel={`category “${category.name}”`}
status={category.content_status}
reason={category.content_review_reason}
onChanged={onReviewChanged}
compact
/>
<div className="grid gap-2">
<Link to={`/categories/${category.id}/packs`}>
<Button variant="outline" className="w-full">
@@ -579,6 +594,7 @@ export function CategoriesPage() {
onEdit={form.openEdit}
onDelete={handleDelete}
onToggleVisibility={handleToggleVisibility}
onReviewChanged={fetchCategories}
/>
))}
</div>
@@ -31,6 +31,8 @@ import {
} from '@/components/ui/select';
import { Plus, Zap, Pencil, Trash2, Loader2, Crown, Eye, EyeOff, ChevronUp, ChevronDown } from 'lucide-react';
import { toast } from 'sonner';
import { ContentReviewControl } from '@/components/content/ContentReviewControl';
import type { ContentReviewStatus } from '@sauci/shared';
// =============================================================================
// Types
@@ -54,6 +56,8 @@ interface DarePack {
category_id: string | null;
created_at: string | null;
dare_count?: number;
content_status?: ContentReviewStatus | null;
content_review_reason?: string | null;
}
interface DarePackFormData {
@@ -511,6 +515,15 @@ export function DarePacksPage() {
</div>
</div>
<div className="flex items-center gap-1">
<ContentReviewControl
table="dare_packs"
entityId={pack.id}
entityLabel={`dare pack “${pack.name}”`}
status={pack.content_status}
reason={pack.content_review_reason}
onChanged={fetchData}
compact
/>
{pack.is_explicit && (
<Badge variant="destructive">
NSFW
@@ -37,6 +37,8 @@ import { Plus, Pencil, Trash2, Zap, Loader2, Clock } from 'lucide-react';
import { Checkbox } from '@/components/ui/checkbox';
import { toast } from 'sonner';
import { IconPreview } from '@/components/ui/icon-picker';
import { ContentReviewControl } from '@/components/content/ContentReviewControl';
import type { ContentReviewStatus } from '@sauci/shared';
// =============================================================================
// Types
@@ -56,6 +58,8 @@ interface Dare {
intensity: number;
suggested_duration_hours: number | null;
created_at: string | null;
content_status?: ContentReviewStatus | null;
content_review_reason?: string | null;
}
// Duration options for dares
@@ -439,6 +443,7 @@ export function DaresPage() {
</TableHead>
<TableHead className="w-12">#</TableHead>
<TableHead>Dare</TableHead>
<TableHead className="w-40">Catalogue</TableHead>
<TableHead className="w-24">Duration</TableHead>
<TableHead className="w-24">Actions</TableHead>
</TableRow>
@@ -462,6 +467,17 @@ export function DaresPage() {
<TableCell>
<span className="line-clamp-2">{dare.text}</span>
</TableCell>
<TableCell>
<ContentReviewControl
table="dares"
entityId={dare.id}
entityLabel={`dare ${((page - 1) * pageSize) + index + 1}`}
status={dare.content_status}
reason={dare.content_review_reason}
onChanged={fetchData}
compact
/>
</TableCell>
<TableCell>
{dare.suggested_duration_hours ? (
<Badge variant="outline" className="text-xs">
@@ -14,6 +14,8 @@ import { AiGeneratorDialog } from '@/components/ai/AiGeneratorDialog';
import { ExtractTopicsDialog } from '@/components/content/ExtractTopicsDialog';
import { PackFormDialog, type PackFormData } from '@/components/content/PackFormDialog';
import { IconPreview } from '@/components/ui/icon-picker';
import { ContentReviewControl } from '@/components/content/ContentReviewControl';
import type { ContentReviewStatus } from '@sauci/shared';
// =============================================================================
// Types
@@ -43,6 +45,8 @@ interface QuestionPack {
created_at: string | null;
scheduled_release_at?: string | null; // Optional for backwards compatibility
release_notified?: boolean; // True if notifications have been sent or should be skipped
content_status?: ContentReviewStatus | null;
content_review_reason?: string | null;
question_count?: number;
topics?: Topic[];
}
@@ -513,6 +517,15 @@ export function PacksPage() {
</div>
</div>
<div className="flex items-center gap-1">
<ContentReviewControl
table="question_packs"
entityId={pack.id}
entityLabel={`pack “${pack.name}”`}
status={pack.content_status}
reason={pack.content_review_reason}
onChanged={fetchData}
compact
/>
{pack.is_explicit && (
<Badge variant="destructive">
NSFW
@@ -43,6 +43,8 @@ import { AiGeneratorDialog } from '@/components/ai/AiGeneratorDialog';
import { AIPolishButton } from '@/components/ai/AIPolishButton';
import { ReviewQuestionsDialog } from '@/components/content/ReviewQuestionsDialog';
import { IconPreview } from '@/components/ui/icon-picker';
import { ContentReviewControl } from '@/components/content/ContentReviewControl';
import type { ContentReviewStatus } from '@sauci/shared';
interface QuestionPack {
id: string;
@@ -70,6 +72,8 @@ interface Question {
created_at: string | null;
question_type?: QuestionType | null;
config?: QuestionConfig | null;
content_status?: ContentReviewStatus | null;
content_review_reason?: string | null;
}
const DEFAULT_AUDIO_DURATION = 60;
@@ -982,6 +986,7 @@ export function QuestionsPage() {
</TableHead>
<TableHead className="w-12">#</TableHead>
<TableHead>Question</TableHead>
<TableHead className="w-40">Catalogue</TableHead>
<TableHead className="w-28">Type</TableHead>
<TableHead className="w-20">Inverse</TableHead>
<TableHead className="w-32">Partner Text</TableHead>
@@ -1009,6 +1014,17 @@ export function QuestionsPage() {
<TableCell>
<span className="line-clamp-2">{question.text}</span>
</TableCell>
<TableCell>
<ContentReviewControl
table="questions"
entityId={question.id}
entityLabel={`question ${((page - 1) * pageSize) + index + 1}`}
status={question.content_status}
reason={question.content_review_reason}
onChanged={fetchData}
compact
/>
</TableCell>
<TableCell>
<div className="space-y-1">
<Badge variant="secondary" className="text-[10px] uppercase tracking-wide">
+3 -1
View File
@@ -13,6 +13,7 @@ import { cn } from '@/lib/utils';
import { useRealtimeSubscription } from '@/hooks/useRealtimeSubscription';
import { RealtimeStatusIndicator } from '@/components/RealtimeStatusIndicator';
import { PaginationControls } from '@/components/ui/pagination';
import { MATCH_TYPE_LABELS } from '@/lib/questionResponses';
interface Profile {
id: string;
@@ -22,7 +23,7 @@ interface Profile {
interface Match {
id: string;
match_type: 'yes_yes' | 'yes_maybe' | 'maybe_maybe';
match_type: keyof typeof MATCH_TYPE_LABELS;
question: {
id: string;
text: string;
@@ -48,6 +49,7 @@ const matchTypeLabels = {
yes_yes: 'Both Yes!',
yes_maybe: 'Yes + Maybe',
maybe_maybe: 'Both Maybe',
both_answered: MATCH_TYPE_LABELS.both_answered,
};
function formatMessageTime(date: Date): string {
+33 -16
View File
@@ -37,9 +37,12 @@ import {
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Crown, Users, MessageCircle, ChevronRight, ThumbsUp, ThumbsDown, Minus, Gift, Image, Video as VideoIcon, Target, User, Eye, EyeOff, CheckCircle, Package, Sparkles, Flame, Trophy, Calendar, AlertCircle, Clock, Pencil } from 'lucide-react';
import { Crown, Users, MessageCircle, ChevronRight, Gift, Image, Video as VideoIcon, Target, User, Eye, EyeOff, CheckCircle, Package, Sparkles, Flame, Trophy, Calendar, AlertCircle, Clock, Pencil } from 'lucide-react';
import { format } from 'date-fns';
import { IconPreview } from '@/components/ui/icon-picker';
import type { AnswerType, QuestionType } from '@sauci/shared';
import { ResponseAnswer } from '@/components/content/ResponseAnswer';
import { MATCH_TYPE_LABELS, type AdminResponseData } from '@/lib/questionResponses';
type UsageReason = 'improve_communication' | 'spice_up_intimacy' | 'deeper_connection' | 'have_fun' | 'strengthen_relationship';
type Gender = 'male' | 'female' | 'non-binary' | 'prefer-not-to-say';
@@ -82,11 +85,13 @@ interface Partner {
interface Response {
id: string;
answer: 'yes' | 'no' | 'maybe';
answer: AnswerType;
response_data: AdminResponseData;
created_at: string | null;
question: {
id: string;
text: string;
question_type: QuestionType;
pack: {
id: string;
name: string;
@@ -96,7 +101,7 @@ interface Response {
interface Match {
id: string;
match_type: 'yes_yes' | 'yes_maybe' | 'maybe_maybe';
match_type: keyof typeof MATCH_TYPE_LABELS;
is_new: boolean;
created_at: string | null;
question: {
@@ -134,12 +139,15 @@ interface OutstandingQuestion {
id: string;
text: string;
intensity: number | null;
question_type: QuestionType;
pack: {
id: string;
name: string;
};
partner_response: {
answer: 'yes' | 'no' | 'maybe';
id: string;
answer: AnswerType;
response_data: AdminResponseData;
created_at: string | null;
};
}
@@ -155,16 +163,11 @@ interface CoupleStreak {
updated_at: string;
}
const answerIcons = {
yes: <ThumbsUp className="h-4 w-4 text-green-500" />,
no: <ThumbsDown className="h-4 w-4 text-red-500" />,
maybe: <Minus className="h-4 w-4 text-yellow-500" />,
};
const matchTypeLabels = {
yes_yes: 'Both Yes!',
yes_maybe: 'Yes + Maybe',
maybe_maybe: 'Both Maybe',
both_answered: MATCH_TYPE_LABELS.both_answered,
};
@@ -510,7 +513,7 @@ export function UserDetailPage() {
const { data: responseData, count: responsesCount, error: responsesError } = await adminData
.from('responses')
.select('id, answer, created_at, question:questions(id, text, pack:question_packs(id, name))', { count: 'exact' })
.select('id, answer, response_data, created_at, question:questions(id, text, question_type, pack:question_packs(id, name))', { count: 'exact' })
.eq('user_id', userId)
.order('created_at', { ascending: false })
.range(responsesFrom, responsesTo);
@@ -618,8 +621,10 @@ export function UserDetailPage() {
const { data: partnerResponses } = await adminData
.from('responses')
.select(`
id,
question_id,
answer,
response_data,
created_at
`)
.eq('user_id', partnerProfile.id)
@@ -634,7 +639,7 @@ export function UserDetailPage() {
// Fetch question details (no pack filter — admin sees all)
const { data: questionDetails } = await adminData
.from('questions')
.select('id, text, intensity, pack:question_packs(id, name)')
.select('id, text, intensity, question_type, pack:question_packs(id, name)')
.in('id', unansweredQuestionIds);
const questionMap = new Map((questionDetails || []).map((q: any) => [q.id, q]));
@@ -649,8 +654,14 @@ export function UserDetailPage() {
id: qId,
text: q.text || '',
intensity: q.intensity || null,
question_type: q.question_type ?? 'swipe',
pack: { id: q.pack?.id || '', name: q.pack?.name || '' },
partner_response: { answer: pr.answer, created_at: pr.created_at },
partner_response: {
id: pr.id,
answer: pr.answer,
response_data: pr.response_data,
created_at: pr.created_at,
},
});
}
}
@@ -871,7 +882,7 @@ export function UserDetailPage() {
<TabsTrigger value="packs" disabled={!profile.couple_id}>Enabled Packs ({enabledPacks.length})</TabsTrigger>
<TabsTrigger value="livedraw" disabled={!profile.couple_id}>Live Draw</TabsTrigger>
</TabsList>
{canViewResponses && <TabsContent value="responses" className="mt-4">{responses.length === 0 ? <Card className="flex flex-col items-center justify-center py-12"><p className="text-muted-foreground">No responses yet</p></Card> : <div className="space-y-4"><div className="rounded-md border"><Table><TableHeader><TableRow><TableHead>Question</TableHead><TableHead>Pack</TableHead><TableHead className="w-24">Answer</TableHead><TableHead className="w-32">Date</TableHead></TableRow></TableHeader><TableBody>{responses.map((r) => (<TableRow key={r.id}><TableCell className="max-w-md">{r.question.pack?.id ? (<Link to={`/packs/${r.question.pack.id}/questions`} className="line-clamp-2 text-primary hover:underline cursor-pointer">{r.question.text}</Link>) : (<span className="line-clamp-2">{r.question.text}</span>)}</TableCell><TableCell>{r.question.pack?.id ? (<Link to={`/packs/${r.question.pack.id}/questions`} className="text-primary hover:underline">{r.question.pack.name}</Link>) : (<span className="text-muted-foreground">{r.question.pack?.name || '—'}</span>)}</TableCell><TableCell><div className="flex items-center gap-2">{answerIcons[r.answer]}<span className="capitalize">{r.answer}</span></div></TableCell><TableCell className="text-muted-foreground text-sm">{r.created_at ? format(new Date(r.created_at), 'MMM d, yyyy') : '—'}</TableCell></TableRow>))}</TableBody></Table></div><PaginationControls page={responsesPage} pageSize={responsesPageSize} totalCount={responsesTotal} onPageChange={setResponsesPage} onPageSizeChange={(size) => { setResponsesPage(1); setResponsesPageSize(size); }} /></div>}</TabsContent>}
{canViewResponses && <TabsContent value="responses" className="mt-4">{responses.length === 0 ? <Card className="flex flex-col items-center justify-center py-12"><p className="text-muted-foreground">No responses yet</p></Card> : <div className="space-y-4"><div className="rounded-md border"><Table><TableHeader><TableRow><TableHead>Question</TableHead><TableHead>Pack</TableHead><TableHead className="w-64">Response</TableHead><TableHead className="w-32">Date</TableHead></TableRow></TableHeader><TableBody>{responses.map((r) => (<TableRow key={r.id}><TableCell className="max-w-md">{r.question.pack?.id ? (<Link to={`/packs/${r.question.pack.id}/questions`} className="line-clamp-2 text-primary hover:underline cursor-pointer">{r.question.text}</Link>) : (<span className="line-clamp-2">{r.question.text}</span>)}</TableCell><TableCell>{r.question.pack?.id ? (<Link to={`/packs/${r.question.pack.id}/questions`} className="text-primary hover:underline">{r.question.pack.name}</Link>) : (<span className="text-muted-foreground">{r.question.pack?.name || '—'}</span>)}</TableCell><TableCell><ResponseAnswer answer={r.answer} responseId={r.id} questionType={r.question.question_type} responseData={r.response_data} responderId={userId} responderName={profile.name || profile.email} /></TableCell><TableCell className="text-muted-foreground text-sm">{r.created_at ? format(new Date(r.created_at), 'MMM d, yyyy') : '—'}</TableCell></TableRow>))}</TableBody></Table></div><PaginationControls page={responsesPage} pageSize={responsesPageSize} totalCount={responsesTotal} onPageChange={setResponsesPage} onPageSizeChange={(size) => { setResponsesPage(1); setResponsesPageSize(size); }} /></div>}</TabsContent>}
{canViewMatches && <TabsContent value="matches" className="mt-4">{matches.length === 0 ? <Card className="flex flex-col items-center justify-center py-12"><p className="text-muted-foreground">No matches yet</p></Card> : <div className="space-y-4"><div className="grid gap-4 md:grid-cols-2">{matches.map((match) => (<Card key={match.id} className="hover:shadow-md transition-shadow"><CardHeader className="pb-2"><div className="flex items-start justify-between"><Badge variant="secondary">{matchTypeLabels[match.match_type]}</Badge>{match.is_new && <Badge variant="default" className="text-xs">New</Badge>}</div><CardDescription className="line-clamp-2 mt-2">{match.question?.text || 'Unknown question'}</CardDescription></CardHeader><CardContent><div className="flex items-center justify-between"><div className="flex items-center gap-1 text-sm text-muted-foreground"><MessageCircle className="h-4 w-4" />{match.message_count} message{match.message_count !== 1 ? 's' : ''}</div>{canViewChats && <Link to={`/users/${userId}/matches/${match.id}`}><Button variant="ghost" size="sm">View Chat<ChevronRight className="ml-1 h-4 w-4" /></Button></Link>}</div></CardContent></Card>))}</div><PaginationControls page={matchesPage} pageSize={matchesPageSize} totalCount={matchesTotal} onPageChange={setMatchesPage} onPageSizeChange={(size) => { setMatchesPage(1); setMatchesPageSize(size); }} /></div>}</TabsContent>}
{canViewMedia && <TabsContent value="media" className="mt-4">{mediaMessages.length === 0 ? <Card className="flex flex-col items-center justify-center py-12"><Image className="h-12 w-12 text-muted-foreground mb-4" /><p className="text-muted-foreground">No media uploaded</p></Card> : <div className="space-y-4"><div className="grid gap-4 grid-cols-2 md:grid-cols-3 lg:grid-cols-4">{mediaMessages.map((message) => (<Card key={message.id} className="overflow-hidden"><div className="aspect-square bg-muted relative">
{message.media_type === 'video' ? (
@@ -938,8 +949,14 @@ export function UserDetailPage() {
</TableCell>
<TableCell>
<div className="flex items-center gap-2">
{answerIcons[q.partner_response.answer]}
<span className="capitalize">{q.partner_response.answer}</span>
<ResponseAnswer
answer={q.partner_response.answer}
responseId={q.partner_response.id}
questionType={q.question_type}
responseData={q.partner_response.response_data}
responderId={partner?.id}
responderName={partner?.name}
/>
</div>
</TableCell>
<TableCell className="text-muted-foreground text-sm">
@@ -0,0 +1,46 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { CONTENT_STATUS_LABELS } from '@/lib/contentReviewStatus';
const { update } = vi.hoisted(() => ({ update: vi.fn() }));
vi.mock('@/hooks/useAuditedSupabase', () => ({
auditedSupabase: { update },
}));
import { updateContentReviewStatus } from './contentReviews';
describe('catalogue review labels', () => {
it('uses archive language for reversible removals', () => {
expect(CONTENT_STATUS_LABELS).toEqual({
unreviewed: 'Needs review',
allowed: 'Allowed',
archived: 'Archived',
});
});
beforeEach(() => {
update.mockReset();
update.mockResolvedValue({ data: null, error: null });
});
it('writes a trimmed, audited review decision', async () => {
await updateContentReviewStatus(
'question_packs',
'pack-1',
'archived',
' Store compliance review ',
);
expect(update).toHaveBeenCalledWith('question_packs', 'pack-1', {
content_status: 'archived',
content_review_reason: 'Store compliance review',
});
});
it('rejects an empty reason before writing', async () => {
await expect(
updateContentReviewStatus('questions', 'question-1', 'allowed', ' '),
).rejects.toThrow('A review reason is required');
expect(update).not.toHaveBeenCalled();
});
});
+29
View File
@@ -0,0 +1,29 @@
import type { ContentReviewStatus } from '@sauci/shared';
import { auditedSupabase } from '@/hooks/useAuditedSupabase';
export { CONTENT_STATUS_LABELS } from '@/lib/contentReviewStatus';
export type ReviewableContentTable =
| 'categories'
| 'question_packs'
| 'questions'
| 'dare_packs'
| 'dares';
export async function updateContentReviewStatus(
table: ReviewableContentTable,
id: string,
status: ContentReviewStatus,
reason: string,
): Promise<void> {
const normalizedReason = reason.trim();
if (!normalizedReason) {
throw new Error('A review reason is required');
}
const { error } = await auditedSupabase.update(table, id, {
content_status: status,
content_review_reason: normalizedReason,
});
if (error) throw error;
}
@@ -10,7 +10,7 @@ type ProfileSettingsApp = Hono<{ Variables: { identity: AuthIdentity } }>;
const profileUpdateSchema = z.object({
name: z.string().trim().min(1).max(100).optional(),
gender: z.enum(['male', 'female', 'non-binary', 'prefer-not-to-say']).nullable().optional(),
usage_reason: z.enum(['improve_communication', 'spice_up_intimacy', 'deeper_connection', 'have_fun', 'strengthen_relationship']).nullable().optional(),
usage_reason: z.enum(['improve_communication', 'reconnect', 'spice_up_intimacy', 'deeper_connection', 'have_fun', 'strengthen_relationship']).nullable().optional(),
max_intensity: z.union([z.literal(1), z.literal(2), z.literal(3), z.literal(4), z.literal(5)]).optional(),
show_explicit_content: z.boolean().optional(),
hide_nsfw: z.boolean().optional(),
@@ -22,7 +22,7 @@ export interface NotificationPreferences extends Record<NotificationPreferenceKe
export interface ProfileUpdate {
name?: string;
gender?: 'male' | 'female' | 'non-binary' | 'prefer-not-to-say' | null;
usage_reason?: 'improve_communication' | 'spice_up_intimacy' | 'deeper_connection' | 'have_fun' | 'strengthen_relationship' | null;
usage_reason?: 'improve_communication' | 'reconnect' | 'spice_up_intimacy' | 'deeper_connection' | 'have_fun' | 'strengthen_relationship' | null;
max_intensity?: 1 | 2 | 3 | 4 | 5;
show_explicit_content?: boolean;
hide_nsfw?: boolean;
+1 -1
View File
@@ -54,7 +54,7 @@ export default function DaresScreen() {
{/* Description */}
<Text style={styles.description}>
Push your boundaries together with tantalising challenges designed to ignite passion and create unforgettable moments.
Try playful challenges together and create unforgettable moments.
</Text>
{/* Feature hints */}
+16 -137
View File
@@ -24,7 +24,6 @@ import Animated, {
import { GradientBackground } from '../../src/components/ui/GradientBackground';
import { GlassCard } from '../../src/components/ui/GlassCard';
import { GlassButton } from '../../src/components/ui/GlassButton';
import { GlassToggle } from '../../src/components/ui/GlassToggle';
import { Paywall } from '../../src/components/paywall';
import { colors, gradients, spacing, typography, radius, shadows } from '../../src/theme';
import { useAuthStore } from '../../src/store';
@@ -44,23 +43,17 @@ const GENDER_OPTIONS: { value: Gender; label: string; icon: string }[] = [
{ value: 'prefer-not-to-say', label: 'Skip', icon: 'remove-circle-outline' },
];
type UsageReason = 'improve_communication' | 'spice_up_intimacy' | 'deeper_connection' | 'have_fun' | 'strengthen_relationship';
type UsageReason = 'improve_communication' | 'reconnect' | 'deeper_connection' | 'have_fun' | 'strengthen_relationship';
const PURPOSE_OPTIONS: { value: UsageReason; label: string; icon: string }[] = [
{ value: 'improve_communication', label: 'Improve communication', icon: 'chatbubbles' },
{ value: 'spice_up_intimacy', label: 'Spice up intimacy', icon: 'flame' },
{ value: 'reconnect', label: 'Feel more connected', icon: 'flame' },
{ value: 'deeper_connection', label: 'Build deeper connection', icon: 'heart' },
{ value: 'have_fun', label: 'Have fun together', icon: 'happy' },
{ value: 'strengthen_relationship', label: 'Strengthen our relationship', icon: 'shield-checkmark' },
];
// Intensity thresholds for backwards compatibility
// When hide_nsfw=true, max_intensity=2 (mild content only)
// When hide_nsfw=false, max_intensity=5 (all content)
const NSFW_OFF_INTENSITY = 2;
const NSFW_ON_INTENSITY = 5;
type Stage = 'avatar' | 'name' | 'gender' | 'purpose' | 'content' | 'notifications';
type Stage = 'avatar' | 'name' | 'gender' | 'purpose' | 'notifications';
export default function OnboardingScreen() {
const router = useRouter();
@@ -72,7 +65,6 @@ export default function OnboardingScreen() {
const [name, setName] = useState(initialUser.current?.name || '');
const [gender, setGender] = useState<Gender | null>(initialUser.current?.gender || null);
const [usageReason, setUsageReason] = useState<UsageReason | null>(null);
const [hideNsfw, setHideNsfw] = useState(initialUser.current?.hide_nsfw ?? false);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [showPaywall, setShowPaywall] = useState(false);
@@ -137,14 +129,9 @@ export default function OnboardingScreen() {
setError('Please select what brings you to Sauci');
return;
}
setStage('content');
Events.onboardingStageComplete('purpose');
};
const handleContentContinue = () => {
setStage('notifications');
Events.onboardingStageComplete('content');
};
setStage('notifications');
Events.onboardingStageComplete('purpose');
};
const handleEnableNotifications = async () => {
setIsLoading(true);
@@ -182,19 +169,18 @@ export default function OnboardingScreen() {
}
}
// Derive intensity from hide_nsfw for backwards compatibility
const maxIntensity: 2 | 5 = hideNsfw ? NSFW_OFF_INTENSITY : NSFW_ON_INTENSITY;
const showExplicitContent = !hideNsfw;
const updatedFields: string[] = ["name", "gender", "usage_reason", "max_intensity", "show_explicit_content", "hide_nsfw"];
const updatedFields: string[] = ["name", "gender", "usage_reason", "max_intensity", "show_explicit_content", "hide_nsfw"];
const updateData = {
name: name.trim(),
gender,
usage_reason: usageReason,
max_intensity: maxIntensity,
show_explicit_content: showExplicitContent,
hide_nsfw: hideNsfw,
onboarding_completed: true,
// Preserve the safest legacy profile values. Catalogue
// eligibility is enforced centrally by reviewed status.
max_intensity: 2 as const,
show_explicit_content: false,
hide_nsfw: true,
onboarding_completed: true,
onboarding_version: REQUIRED_ONBOARDING_VERSION,
};
@@ -495,73 +481,6 @@ export default function OnboardingScreen() {
</Animated.View>
);
case 'content':
return (
<Animated.View
key="content"
entering={SlideInRight.duration(400)}
exiting={FadeOut.duration(200)}
style={styles.stageContainer}
>
<View style={styles.header}>
<LinearGradient
colors={gradients.primary as [string, string]}
style={styles.iconContainer}
>
<Ionicons name="flame" size={44} color={colors.text} />
</LinearGradient>
<Text style={styles.title}>Content Preferences</Text>
<Text style={styles.subtitle}>
Choose what type of content you'd like to see
</Text>
</View>
<GlassCard style={styles.card}>
<View style={styles.contentToggleRow}>
<View style={styles.contentToggleLeft}>
<LinearGradient
colors={gradients.primary as [string, string]}
style={styles.contentToggleIcon}
>
<Ionicons name="eye-off" size={24} color={colors.text} />
</LinearGradient>
<View style={styles.contentToggleText}>
<Text style={styles.contentToggleLabel}>Hide Adult Content</Text>
<Text style={styles.contentToggleDescription}>
Only show mild, family-friendly question packs
</Text>
</View>
</View>
<GlassToggle
value={hideNsfw}
onValueChange={setHideNsfw}
/>
</View>
<Text style={styles.contentNote}>
You can change this anytime in settings
</Text>
{error && (
<View style={styles.errorContainer}>
<Ionicons name="alert-circle" size={16} color={colors.error} />
<Text style={styles.errorText}>{error}</Text>
</View>
)}
</GlassCard>
<View style={styles.footer}>
<GlassButton
onPress={handleContentContinue}
fullWidth
size="lg"
>
Continue
</GlassButton>
</View>
</Animated.View>
);
case 'notifications':
return (
<Animated.View
@@ -650,8 +569,7 @@ export default function OnboardingScreen() {
style={styles.backButton}
onPress={() => {
setError(null);
if (stage === 'notifications') setStage('content');
else if (stage === 'content') setStage('purpose');
if (stage === 'notifications') setStage('purpose');
else if (stage === 'purpose') setStage('gender');
else if (stage === 'gender') setStage('name');
else if (stage === 'name') setStage('avatar');
@@ -667,8 +585,7 @@ export default function OnboardingScreen() {
<View style={[styles.progressDot, stage === 'name' && styles.progressDotActive]} />
<View style={[styles.progressDot, stage === 'gender' && styles.progressDotActive]} />
<View style={[styles.progressDot, stage === 'purpose' && styles.progressDotActive]} />
<View style={[styles.progressDot, stage === 'content' && styles.progressDotActive]} />
<View style={[styles.progressDot, stage === 'notifications' && styles.progressDotActive]} />
<View style={[styles.progressDot, stage === 'notifications' && styles.progressDotActive]} />
</View>
<View style={styles.backButtonPlaceholder} />
</Animated.View>
@@ -944,44 +861,6 @@ const styles = StyleSheet.create({
color: colors.text,
fontWeight: '600',
},
contentToggleRow: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
},
contentToggleLeft: {
flex: 1,
flexDirection: 'row',
alignItems: 'center',
marginRight: spacing.md,
},
contentToggleIcon: {
width: 48,
height: 48,
borderRadius: 24,
alignItems: 'center',
justifyContent: 'center',
marginRight: spacing.md,
},
contentToggleText: {
flex: 1,
},
contentToggleLabel: {
...typography.headline,
color: colors.text,
},
contentToggleDescription: {
...typography.caption1,
color: colors.textSecondary,
marginTop: 2,
},
contentNote: {
...typography.caption1,
color: colors.textSecondary,
textAlign: 'center',
marginTop: spacing.lg,
},
radioOuterSelected: {
borderColor: colors.primary,
},
+2 -2
View File
@@ -34,7 +34,7 @@ export default function QuizScreen() {
style={styles.content}
>
{/* Title section */}
<Text style={styles.label}>INTIMACY</Text>
<Text style={styles.label}>CONNECTION</Text>
<Text style={styles.title}>Quiz</Text>
{/* Decorative separator */}
@@ -61,7 +61,7 @@ export default function QuizScreen() {
<View style={styles.features}>
<View style={styles.featureItem}>
<Ionicons name="sparkles" size={16} color={ACCENT_COLOR} />
<Text style={styles.featureText}>Uncover hidden desires</Text>
<Text style={styles.featureText}>Learn what makes each other tick</Text>
</View>
<View style={styles.featureItem}>
<Ionicons name="flame-outline" size={16} color={ACCENT_COLOR} />
+1 -1
View File
@@ -489,7 +489,7 @@ export default function LoginScreen() {
/>
<Text style={styles.title}>Sauci</Text>
<Text style={styles.subtitle}>
Explore intimacy together
Grow closer together
</Text>
</Animated.View>
-25
View File
@@ -236,14 +236,6 @@ export default function PackDetailsScreen() {
{category ? category.name.toUpperCase() : 'PREVIEW'}
</Text>
{/* Explicit badge */}
{pack?.is_explicit && (
<View style={styles.explicitBadge}>
<Ionicons name="flame" size={12} color={colors.text} />
<Text style={styles.explicitBadgeText}>18+ EXPLICIT</Text>
</View>
)}
{/* Icon with enhanced glow for premium */}
<View style={styles.iconWrapper}>
{pack?.is_premium && (
@@ -585,23 +577,6 @@ const styles = StyleSheet.create({
labelPremium: {
color: colors.premium.gold,
},
explicitBadge: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: colors.error,
paddingHorizontal: spacing.md,
paddingVertical: spacing.xs,
borderRadius: radius.full,
gap: spacing.xs,
marginBottom: spacing.md,
},
explicitBadgeText: {
...typography.caption2,
fontWeight: '700',
color: colors.text,
letterSpacing: 1,
},
// Removed glow styles
iconGlowOuter: {
display: 'none',
@@ -7,7 +7,7 @@ describe('packsStore standalone API integration', () => {
jest.restoreAllMocks();
usePacksStore.setState({
packs: [], categories: [], enabledPackIds: [], packProgress: new Map(),
isLoading: false, showAllIntensities: false,
isLoading: false,
} as any);
useAuthStore.setState({ user: { id: 'me', couple_id: 'c1', hide_nsfw: true } } as any);
});
@@ -92,18 +92,6 @@ export function BoutiquePackCard({
{/* Content */}
<View style={styles.content}>
{/* Explicit Badge (top-left) */}
{pack.is_explicit && (
<View style={styles.explicitBadge}>
<Ionicons
name="flame"
size={10}
color={colors.text}
/>
<Text style={styles.explicitBadgeText}>18+</Text>
</View>
)}
{/* PRO Badge (top-right) */}
{pack.is_premium && (
<View style={[
@@ -242,24 +230,6 @@ const styles = StyleSheet.create({
padding: spacing.md,
justifyContent: 'space-between',
},
explicitBadge: {
position: 'absolute',
top: spacing.sm,
left: spacing.sm,
flexDirection: 'row',
alignItems: 'center',
backgroundColor: colors.error,
paddingHorizontal: spacing.sm,
paddingVertical: 3,
borderRadius: radius.xs,
gap: 3,
zIndex: 10,
},
explicitBadgeText: {
...typography.caption2,
color: colors.text,
fontWeight: '700',
},
proBadge: {
position: 'absolute',
top: spacing.sm,
@@ -54,12 +54,6 @@ export function ContentTile({ pack, isLocked, isNew = false, progress, onPress }
/>
</View>
<View style={styles.badges}>
{pack.is_explicit && (
<View style={styles.explicitBadge}>
<Ionicons name="flame" size={10} color={colors.text} />
<Text style={styles.explicitBadgeText}>18+</Text>
</View>
)}
{isNew && (
<View style={styles.newBadge}>
<Text style={styles.newBadgeText}>NEW</Text>
@@ -129,20 +123,6 @@ const styles = StyleSheet.create({
flexDirection: 'row',
gap: spacing.xs,
},
explicitBadge: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: colors.error,
paddingHorizontal: spacing.sm,
paddingVertical: 2,
borderRadius: radius.xs,
gap: 2,
},
explicitBadgeText: {
...typography.caption2,
fontWeight: '700',
color: colors.text,
},
newBadge: {
backgroundColor: 'rgba(255, 255, 255, 0.95)',
paddingHorizontal: spacing.sm,
@@ -34,7 +34,7 @@ const FEATURES = [
{
icon: "layers" as const,
title: "All Premium Packs",
description: "Unlock every intimate collection",
description: "Unlock every question collection",
},
{
icon: "sparkles" as const,
@@ -206,7 +206,7 @@ export function Paywall({ visible, onClose, onSuccess, offeringId }: PaywallProp
<Text style={styles.title}>Sauci Pro</Text>
<Text style={styles.subtitle}>
Unlock the full intimate experience
Unlock the full couples experience
</Text>
</View>
@@ -48,7 +48,7 @@ const STEPS: TutorialStep[] = [
{
key: "photos",
title: "Share Pictures Privately",
description: "Send photos that only your partner can see. Your intimate moments stay between the two of you, safe and private.",
description: "Send photos that only your partner can see. Your shared moments stay between the two of you, safe and private.",
icon: "images",
gradient: gradients.premiumRose as [string, string],
},
@@ -8,17 +8,17 @@ export interface IntensityLevelConfig {
}
export const INTENSITY_LEVELS: IntensityLevelConfig[] = [
{ level: 1, label: 'Gentle', emoji: '💭', description: 'Pure emotional connection & non-sexual bonding' },
{ level: 2, label: 'Warm', emoji: '💕', description: 'Romantic atmosphere & affectionate touch' },
{ level: 3, label: 'Playful', emoji: '😏', description: 'Light sexual exploration & sensual discovery' },
{ level: 4, label: 'Steamy', emoji: '🔥', description: 'Explicit sexual activities & moderate adventure' },
{ level: 5, label: 'Intense', emoji: '🌶️', description: 'Advanced/BDSM/Extreme exploration' },
{ level: 1, label: 'Gentle', emoji: '💭', description: 'Easy conversation and everyday connection' },
{ level: 2, label: 'Warm', emoji: '💕', description: 'Romance, appreciation, and thoughtful moments' },
{ level: 3, label: 'Playful', emoji: '✨', description: 'Light-hearted questions and shared laughter' },
{ level: 4, label: 'Adventurous', emoji: '🌍', description: 'New experiences and ideas to try together' },
{ level: 5, label: 'Deep', emoji: '🌱', description: 'Meaningful reflection and honest conversation' },
];
export const HEAT_COLORS = [
'#9b59b6', // Purple - gentle
'#e94560', // Rose - warm
'#ff6b6b', // Coral - playful
'#ff4757', // Red - steamy
'#ff4757', // Red - adventurous
'#ff3333', // Bright red - intense
];
@@ -160,9 +160,9 @@ export function ProfileScreen() {
/>
<View style={styles.divider} />
<MenuItem
icon="options-outline"
label="Preferences"
description="Comfort zone and security"
icon="lock-closed-outline"
label="App Lock"
description="Biometric access controls"
onPress={() => router.push('/(app)/settings/preferences')}
/>
</SettingsSection>
@@ -1,5 +1,5 @@
import React from 'react';
import { View, StyleSheet } from 'react-native';
import { Text, View, StyleSheet } from 'react-native';
import { SettingsSection } from './SettingsSection';
import { SwitchItem } from './SwitchItem';
import { colors, spacing } from '../../../theme';
@@ -10,9 +10,6 @@ interface PrivacySettingsProps {
biometricType: string;
isUpdatingBiometric: boolean;
onBiometricToggle: (value: boolean) => void;
hideNsfw: boolean;
isUpdatingHideNsfw: boolean;
onHideNsfwToggle: (value: boolean) => void;
}
export const PrivacySettings: React.FC<PrivacySettingsProps> = ({
@@ -21,44 +18,37 @@ export const PrivacySettings: React.FC<PrivacySettingsProps> = ({
biometricType,
isUpdatingBiometric,
onBiometricToggle,
hideNsfw,
isUpdatingHideNsfw,
onHideNsfwToggle,
}) => {
return (
<SettingsSection title="Preferences" delay={375}>
{/* Hide Adult Content Toggle */}
<SwitchItem
icon="eye-off-outline"
label="Hide Adult Content"
description="Only show mild, family-friendly question packs"
value={hideNsfw}
onValueChange={onHideNsfwToggle}
disabled={isUpdatingHideNsfw}
/>
{/* Biometric Toggle */}
<SettingsSection title="Security" delay={375}>
{biometricAvailable && (
<>
<View style={styles.divider} />
<SwitchItem
icon={biometricType === "Face ID" || biometricType === "Face Recognition" ? "scan-outline" : "finger-print-outline"}
label={biometricType}
description="Require unlock when opening app"
value={biometricEnabled}
onValueChange={onBiometricToggle}
disabled={isUpdatingBiometric}
/>
</>
<SwitchItem
icon={biometricType === "Face ID" || biometricType === "Face Recognition" ? "scan-outline" : "finger-print-outline"}
label={biometricType}
description="Require unlock when opening app"
value={biometricEnabled}
onValueChange={onBiometricToggle}
disabled={isUpdatingBiometric}
/>
)}
{!biometricAvailable && (
<View style={styles.unavailableRow}>
<Text style={styles.unavailableText}>
Biometric app lock is not available on this device.
</Text>
</View>
)}
</SettingsSection>
);
};
const styles = StyleSheet.create({
divider: {
height: 1,
backgroundColor: colors.border,
marginVertical: spacing.lg,
unavailableRow: {
paddingHorizontal: spacing.lg,
paddingVertical: spacing.md,
},
unavailableText: {
color: colors.textSecondary,
lineHeight: 20,
},
});
@@ -1,6 +1,6 @@
import { useState, useEffect, useCallback } from 'react';
import { Alert, Linking, Platform } from 'react-native';
import { useAuthStore, useSubscriptionStore, usePacksStore } from '../../../store';
import { useAuthStore, useSubscriptionStore } from '../../../store';
import { profileSettingsApi } from '../../../lib/profileSettingsApi';
import { Events } from '../../../lib/analytics';
import { useAvatarPicker } from '../../../hooks/useAvatarPicker';
@@ -16,15 +16,8 @@ import {
getBiometricType,
} from '../../../lib/biometricAuth';
// Intensity thresholds for backwards compatibility
// When hide_nsfw=true, max_intensity=2 (mild content only)
// When hide_nsfw=false, max_intensity=5 (all content)
const NSFW_OFF_INTENSITY = 2;
const NSFW_ON_INTENSITY = 5;
export function useProfileSettings() {
const { user, fetchUser } = useAuthStore();
const { fetchPacks } = usePacksStore();
const { restorePurchases, isPurchasing } = useSubscriptionStore();
// Name Editing
@@ -59,10 +52,6 @@ export function useProfileSettings() {
onUploadError: handleAvatarUploadError,
});
// Hide NSFW
const [hideNsfw, setHideNsfw] = useState(!!user?.hide_nsfw);
const [isUpdatingHideNsfw, setIsUpdatingHideNsfw] = useState(false);
// Notifications
const [pushEnabled, setPushEnabled] = useState(!!user?.push_token);
const [isUpdatingPush, setIsUpdatingPush] = useState(false);
@@ -82,7 +71,6 @@ export function useProfileSettings() {
setNewName(user.name);
}
setPushEnabled(!!user?.push_token);
setHideNsfw(!!user?.hide_nsfw);
}, [user]);
// Check biometric availability
@@ -211,37 +199,6 @@ export function useProfileSettings() {
}
};
const handleHideNsfwToggle = async (value: boolean) => {
if (!user?.id) return;
const previous = hideNsfw;
setHideNsfw(value);
setIsUpdatingHideNsfw(true);
// Derive intensity from hide_nsfw for backwards compatibility
const maxIntensity = value ? NSFW_OFF_INTENSITY : NSFW_ON_INTENSITY;
const showExplicitContent = !value;
try {
await profileSettingsApi.updateProfile({
hide_nsfw: value,
max_intensity: maxIntensity,
show_explicit_content: showExplicitContent,
});
// Refresh user data and packs to reflect the change
await fetchUser();
await fetchPacks();
Events.profileUpdated(["hide_nsfw", "max_intensity", "show_explicit_content"]);
} catch (error) {
// Revert on error
setHideNsfw(previous);
Alert.alert("Error", "Failed to update preference. Please try again.");
} finally {
setIsUpdatingHideNsfw(false);
}
};
// Subscription Actions
const handleRestorePurchases = async () => {
const restored = await restorePurchases();
@@ -270,8 +227,6 @@ export function useProfileSettings() {
biometricEnabled: biometricEnabledState,
biometricType,
isUpdatingBiometric,
hideNsfw,
isUpdatingHideNsfw,
showPaywall,
setShowPaywall,
isPurchasing,
@@ -282,7 +237,6 @@ export function useProfileSettings() {
handleAvatarPress,
handlePushToggle,
handleBiometricToggle,
handleHideNsfwToggle,
handleRestorePurchases,
handleManageSubscription,
};
@@ -7,14 +7,14 @@ import { useProfileSettings } from '../hooks';
import { ScreenHeader, PrivacySettings } from '../components';
/**
* Preferences sub-screen for content and biometric settings.
* App-lock settings shared by iOS and Android.
*/
export function PreferencesScreen() {
const settings = useProfileSettings();
return (
<GradientBackground>
<ScreenHeader title="Preferences" />
<ScreenHeader title="App Lock" />
<ScrollView
style={styles.container}
contentContainerStyle={styles.content}
@@ -26,10 +26,7 @@ export function PreferencesScreen() {
biometricType={settings.biometricType}
isUpdatingBiometric={settings.isUpdatingBiometric}
onBiometricToggle={settings.handleBiometricToggle}
hideNsfw={settings.hideNsfw}
isUpdatingHideNsfw={settings.isUpdatingHideNsfw}
onHideNsfwToggle={settings.handleHideNsfwToggle}
/>
/>
</ScrollView>
</GradientBackground>
);
@@ -17,7 +17,7 @@ export const SwipeNoPacksState = ({ onBrowsePacks }: SwipeNoPacksStateProps) =>
badgeText="NO PACKS ENABLED"
description="Select the question packs that interest you and your partner. Each pack explores different aspects of your relationship."
features={[
{ icon: "flame-outline", text: "From playful to passionate", color: accent },
{ icon: "flame-outline", text: "From playful to adventurous", color: accent },
{ icon: "shield-checkmark-outline", text: "Safe space to explore", color: accent },
{ icon: "infinite-outline", text: "New packs added regularly", color: accent },
]}
@@ -38,10 +38,10 @@ export const SwipeNoPartnerState = ({ hasCouple, onPairPress }: SwipeNoPartnerSt
}
features={[
{ icon: "lock-closed-outline", text: "Private and secure", color: accent },
{ icon: "sparkles", text: "Discover hidden desires", color: accent },
{ icon: "sparkles", text: "Discover new things together", color: accent },
{ icon: "chatbubble-ellipses-outline", text: "Chat about your matches", color: accent },
]}
teaser={hasCouple ? "Your partner is just a code away" : "Begin your intimate journey together"}
teaser={hasCouple ? "Your partner is just a code away" : "Begin your journey together"}
action={{
label: hasCouple ? "View Invite Code" : "Pair Now",
onPress: onPairPress,
+1 -1
View File
@@ -5,7 +5,7 @@ import type { NotificationPreferences } from '../store/notificationPreferencesSt
export interface ProfileUpdate {
name?: string;
gender?: Gender | null;
usage_reason?: 'improve_communication' | 'spice_up_intimacy' | 'deeper_connection' | 'have_fun' | 'strengthen_relationship' | null;
usage_reason?: 'improve_communication' | 'reconnect' | 'spice_up_intimacy' | 'deeper_connection' | 'have_fun' | 'strengthen_relationship' | null;
max_intensity?: 1 | 2 | 3 | 4 | 5;
show_explicit_content?: boolean;
hide_nsfw?: boolean;
+1 -11
View File
@@ -16,13 +16,11 @@ interface PacksState {
enabledPackIds: string[];
packProgress: Map<string, PackProgressData>;
isLoading: boolean;
showAllIntensities: boolean;
fetchPacks: () => Promise<void>;
fetchEnabledPacks: () => Promise<void>;
fetchPackProgress: () => Promise<void>;
ensureEnabledPacksLoaded: () => Promise<void>;
togglePack: (packId: string) => Promise<{ success: boolean; reason?: string }>;
setShowAllIntensities: (value: boolean) => void;
clearPacks: () => void;
getPackProgress: (packId: string) => PackProgressData | undefined;
}
@@ -33,14 +31,12 @@ export const usePacksStore = create<PacksState>((set, get) => ({
enabledPackIds: [],
packProgress: new Map(),
isLoading: false,
showAllIntensities: false,
fetchPacks: async () => {
set({ isLoading: true });
try {
const showAllIntensities = get().showAllIntensities;
const catalog = await apiClient.get<{ categories: Category[]; packs: QuestionPack[] }>(
`/v1/packs?showAllIntensities=${showAllIntensities}`,
'/v1/packs?showAllIntensities=false',
);
set({ packs: catalog.packs, categories: catalog.categories });
await get().fetchEnabledPacks();
@@ -111,12 +107,6 @@ export const usePacksStore = create<PacksState>((set, get) => ({
return { success: true };
},
setShowAllIntensities: (value: boolean) => {
set({ showAllIntensities: value });
// Refetch packs with new filter setting
get().fetchPacks();
},
fetchPackProgress: async () => {
if (!useAuthStore.getState().user?.id) {
set({ packProgress: new Map() });
+27
View File
@@ -0,0 +1,27 @@
# Production catalogue snapshots
These files are immutable, version-controlled backups of Sauci's
developer-authored catalogue. They deliberately exclude subscriber accounts,
couples, responses, matches, messages, media, subscriptions, and other customer
data.
`production-2026-08-28.json` contains every production row from:
- `categories`
- `topics`
- `question_packs`
- `questions`, including soft-deleted rows and inverse links
- `dare_packs`
- `dares`
- `pack_topics`
The snapshot is archival input, not an automatically executed seed. Restoration
must be implemented as an explicit, reviewed operation that validates the target
environment, schema compatibility, foreign keys, row counts, and checksum before
writing anything.
Verify the checked-in snapshot from this directory with:
```sh
shasum -a 256 -c production-2026-08-28.sha256
```
File diff suppressed because it is too large. Load diff
@@ -0,0 +1 @@
042551fd33c8994209f44b1a269716c82e8a0d69b87434373c16407d6e7d48ff production-2026-08-28.json
+5 -2
View File
@@ -46,5 +46,8 @@ enable_confirmations = false
[functions.revenuecat-webhook]
verify_jwt = false
[functions.sync-subscription]
verify_jwt = false
[functions.sync-subscription]
verify_jwt = false
[functions.redeem-code]
verify_jwt = false
@@ -0,0 +1,40 @@
export type ResponseMediaDescriptor = {
type: "photo" | "audio";
path: string;
};
export function canViewResponseMedia(
role: unknown,
permissions: unknown,
): boolean {
return role === "super_admin" ||
(Array.isArray(permissions) && permissions.includes("view_responses"));
}
export function getResponseMediaDescriptor(
responseData: unknown,
): ResponseMediaDescriptor | null {
if (!responseData || typeof responseData !== "object") return null;
const data = responseData as Record<string, unknown>;
if (data.type !== "photo" && data.type !== "audio") return null;
if (typeof data.media_path !== "string" || !data.media_path.trim()) {
return null;
}
return {
type: data.type,
path: normalizeResponseMediaPath(data.media_path),
};
}
export function normalizeResponseMediaPath(mediaPath: string): string {
if (mediaPath.startsWith("http")) {
const parts = mediaPath.split("/response-media/");
if (parts.length > 1) {
return decodeURIComponent(parts[1].split("?")[0]);
}
}
return mediaPath;
}
@@ -0,0 +1,144 @@
// @ts-nocheck
import "jsr:@supabase/functions-js/edge-runtime.d.ts";
import { createClient } from "https://esm.sh/@supabase/supabase-js@2.45.0?target=deno";
import { canViewResponseMedia, getResponseMediaDescriptor } from "./helpers.ts";
const corsHeaders = {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Headers":
"authorization, x-client-info, apikey, content-type",
"Access-Control-Allow-Methods": "POST, OPTIONS",
};
Deno.serve(async (req) => {
if (req.method === "OPTIONS") {
return new Response("ok", { headers: corsHeaders });
}
if (req.method !== "POST") {
return json({ error: "Method not allowed" }, 405);
}
try {
const supabaseAdmin = createClient(
Deno.env.get("SUPABASE_URL")!,
Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!,
);
const userId = await getUserIdFromRequest(supabaseAdmin, req);
await assertResponseAccess(supabaseAdmin, userId);
const body = await req.json().catch(() => ({}));
const responseId = typeof body?.responseId === "string"
? body.responseId
: null;
if (!responseId) {
return json({ error: "Missing responseId" }, 400);
}
const { data: response, error: responseError } = await supabaseAdmin
.from("responses")
.select("id, response_data")
.eq("id", responseId)
.maybeSingle();
if (responseError || !response) {
return json({ error: "Response not found" }, 404);
}
const media = getResponseMediaDescriptor(response.response_data);
if (!media) {
return json({ error: "Response has no supported media" }, 400);
}
const { data: mediaBlob, error: downloadError } = await supabaseAdmin
.storage
.from("response-media")
.download(media.path);
if (downloadError || !mediaBlob) {
console.error(
"admin-response-media download failed",
downloadError?.message,
);
return json({ error: "Failed to load response media" }, 500);
}
const fallbackType = media.type === "photo" ? "image/jpeg" : "audio/mp4";
return new Response(mediaBlob, {
status: 200,
headers: {
...corsHeaders,
"Content-Type": mediaBlob.type || fallbackType,
"Cache-Control": "private, no-store",
"X-Content-Type-Options": "nosniff",
},
});
} catch (error) {
const message = error instanceof Error ? error.message : "Unknown error";
if (
message.includes("authorization header") ||
message.startsWith("Invalid token")
) {
return json({ error: "Unauthorized" }, 401);
}
if (message === "Access denied") {
return json({ error: "Forbidden" }, 403);
}
console.error("admin-response-media error", error);
return json({ error: "Internal server error" }, 500);
}
});
function json(body: unknown, status: number) {
return new Response(JSON.stringify(body), {
status,
headers: {
...corsHeaders,
"Content-Type": "application/json",
"Cache-Control": "no-store",
},
});
}
async function getUserIdFromRequest(
supabaseAdmin: ReturnType<typeof createClient>,
req: Request,
) {
const authHeader = req.headers.get("Authorization");
if (!authHeader) {
throw new Error("Missing authorization header");
}
const { data, error } = await supabaseAdmin.auth.getUser(
authHeader.replace("Bearer ", ""),
);
if (error || !data.user) {
throw new Error(`Invalid token: ${error?.message || "No user"}`);
}
return data.user.id;
}
async function assertResponseAccess(
supabaseAdmin: ReturnType<typeof createClient>,
userId: string,
) {
const { data, error } = await supabaseAdmin
.from("admin_users")
.select("role, permissions")
.eq("user_id", userId)
.maybeSingle();
if (error || !data) {
throw new Error("Access denied");
}
if (!canViewResponseMedia(data.role, data.permissions)) {
throw new Error("Access denied");
}
}
+25 -14
View File
@@ -185,23 +185,34 @@ Deno.serve(async (req) => {
const { data: safePacks, error: safePacksError } = await supabase
.from("question_packs")
.select("id")
.select("id, category_id, category:categories(content_status)")
.lte("avg_intensity", 2)
.eq("is_public", true);
.eq("is_public", true)
.eq("content_status", "allowed");
if (safePacksError) {
console.error("Failed to fetch safe packs:", safePacksError);
} else if (safePacks?.length) {
const { error: insertError } = await supabase
.from("couple_packs")
.insert(safePacks.map((pack: { id: string }) => ({
couple_id: couple.id,
pack_id: pack.id,
enabled: true,
})));
} else {
const eligibleSafePacks = (safePacks ?? []).filter((pack) => {
const category = Array.isArray(pack.category)
? pack.category[0]
: pack.category;
return pack.category_id === null
|| category?.content_status === "allowed";
});
if (insertError) {
console.error("Failed to enable safe packs:", insertError);
if (eligibleSafePacks.length) {
const { error: insertError } = await supabase
.from("couple_packs")
.insert(eligibleSafePacks.map((pack: { id: string }) => ({
couple_id: couple.id,
pack_id: pack.id,
enabled: true,
})));
if (insertError) {
console.error("Failed to enable safe packs:", insertError);
}
}
}
@@ -213,8 +224,8 @@ Deno.serve(async (req) => {
}),
{ headers: { ...corsHeaders, "Content-Type": "application/json" } }
);
}
}
}
}
if (method === "DELETE") {
// Get user's current couple
@@ -61,8 +61,9 @@ Deno.serve(async (req) => {
// Get all packs due for release
const { data: duePacks, error: fetchError } = await supabase
.from("question_packs")
.select("id, name, description")
.select("id, name, description, category_id, category:categories(content_status)")
.eq("is_public", false)
.eq("content_status", "allowed")
.eq("release_notified", false)
.not("scheduled_release_at", "is", null)
.lte("scheduled_release_at", new Date().toISOString());
@@ -75,14 +76,22 @@ Deno.serve(async (req) => {
);
}
if (!duePacks || duePacks.length === 0) {
const eligibleDuePacks = (duePacks ?? []).filter((pack) => {
const category = Array.isArray(pack.category)
? pack.category[0]
: pack.category;
return pack.category_id === null
|| category?.content_status === "allowed";
});
if (eligibleDuePacks.length === 0) {
return new Response(
JSON.stringify({ released: 0, message: "No packs due for release" }),
{ headers: { "Content-Type": "application/json" } }
);
}
const packIds = duePacks.map(p => p.id);
const packIds = eligibleDuePacks.map(p => p.id);
// Make packs public
const { error: updateError } = await supabase
@@ -133,7 +142,7 @@ Deno.serve(async (req) => {
}
// Send notification for each released pack
for (const pack of duePacks) {
for (const pack of eligibleDuePacks) {
messages.push({
to: user.push_token!,
title: "New pack available",
@@ -150,12 +159,12 @@ Deno.serve(async (req) => {
await sendExpoPushNotifications(messages);
}
console.log(`Released ${duePacks.length} packs, sent ${messages.length} notifications`);
console.log(`Released ${eligibleDuePacks.length} packs, sent ${messages.length} notifications`);
return new Response(
JSON.stringify({
released: duePacks.length,
packNames: duePacks.map(p => p.name),
released: eligibleDuePacks.length,
packNames: eligibleDuePacks.map(p => p.name),
notificationsSent: messages.length,
}),
{ headers: { "Content-Type": "application/json" } }
@@ -96,11 +96,31 @@ Deno.serve(async (req) => {
// Fetch the question to get its type
const { data: question, error: questionError } = await supabase
.from("questions")
.select("id, question_type")
.select(`
id,
question_type,
content_status,
pack:question_packs!inner(
content_status,
category_id,
category:categories(content_status)
)
`)
.eq("id", question_id)
.single();
if (questionError || !question) {
const questionPack = Array.isArray(question?.pack)
? question.pack[0]
: question?.pack;
const questionCategory = Array.isArray(questionPack?.category)
? questionPack.category[0]
: questionPack?.category;
const questionIsAllowed = question?.content_status === "allowed"
&& questionPack?.content_status === "allowed"
&& (questionPack.category_id === null
|| questionCategory?.content_status === "allowed");
if (questionError || !question || !questionIsAllowed) {
return new Response(
JSON.stringify({ error: "Question not found" }),
{ status: 404, headers: { ...corsHeaders, "Content-Type": "application/json" } }
@@ -125,11 +125,31 @@ Deno.serve(async (req) => {
// Fetch the question to get its type
const { data: question, error: questionError } = await supabase
.from("questions")
.select("id, question_type")
.select(`
id,
question_type,
content_status,
pack:question_packs!inner(
content_status,
category_id,
category:categories(content_status)
)
`)
.eq("id", question_id)
.single();
if (questionError || !question) {
const questionPack = Array.isArray(question?.pack)
? question.pack[0]
: question?.pack;
const questionCategory = Array.isArray(questionPack?.category)
? questionPack.category[0]
: questionPack?.category;
const questionIsAllowed = question?.content_status === "allowed"
&& questionPack?.content_status === "allowed"
&& (questionPack.category_id === null
|| questionCategory?.content_status === "allowed");
if (questionError || !question || !questionIsAllowed) {
return new Response(
JSON.stringify({ error: "Question not found" }),
{ status: 404, headers: { ...corsHeaders, "Content-Type": "application/json" } }
@@ -142,7 +162,7 @@ Deno.serve(async (req) => {
.select("answer, response_data")
.eq("user_id", user.id)
.eq("question_id", question_id)
.maybeSingle();
.maybeSingle();
if (currentResponseError) {
return new Response(
@@ -157,14 +177,14 @@ Deno.serve(async (req) => {
{ status: 404, headers: { ...corsHeaders, "Content-Type": "application/json" } }
);
}
// If answer hasn't changed and no response_data updates, return early
if (currentResponse.answer === new_answer && typeof response_data === "undefined") {
return new Response(
JSON.stringify({ success: true, message: "Answer unchanged" } as UpdateResponseResult),
{ headers: { ...corsHeaders, "Content-Type": "application/json" } }
);
}
}
// Check if a match exists for this question
const { data: existingMatch } = await supabase
@@ -174,7 +194,7 @@ Deno.serve(async (req) => {
.eq("question_id", question_id)
.maybeSingle();
// Get partner's response
// Get partner's response
const { data: partnerResponse } = await supabase
.from("responses")
.select("answer, response_data, user_id")
@@ -196,7 +216,7 @@ Deno.serve(async (req) => {
[user.id]: normalizedResponseData ?? null,
[partnerResponse.user_id]: partnerResponse.response_data ?? null,
}
: null;
: null;
// Case 1: Changing to "no" AND match exists - requires confirmation
if (new_answer === "no" && existingMatch) {
@@ -247,7 +267,7 @@ Deno.serve(async (req) => {
result.match_deleted = true;
}
// Case 2: Changing FROM "no" to yes/maybe - may create a new match
if (currentResponse.answer === "no" && new_answer !== "no" && partnerResponse) {
if (matchType) {
@@ -309,7 +329,7 @@ Deno.serve(async (req) => {
.from("responses")
.update(responseUpdate)
.eq("user_id", user.id)
.eq("question_id", question_id);
.eq("question_id", question_id);
if (updateError) {
return new Response(
@@ -15,15 +15,9 @@ AS $$
|| function_name;
$$;
-- For safety, abort if supabase_url is not set
DO $$
BEGIN
IF (SELECT supabase_url FROM app_config LIMIT 1) IS NULL THEN
RAISE EXCEPTION 'app_config.supabase_url must be set before running this migration. '
'Run: UPDATE app_config SET supabase_url = ''https://<project-ref>.supabase.co'';';
END IF;
END;
$$;
-- Resolve the URL when each job runs, rather than while migrations are applied.
-- A fresh local database therefore remains reproducible and fails closed until
-- its environment-specific URL is configured.
-- Safely unschedule all existing cron jobs (ignore if they don't exist)
DO $$
@@ -56,119 +50,95 @@ $$;
SELECT cron.schedule(
'send-pack-change-notifications',
'*/5 * * * *',
format(
$cmd$
$cmd$
SELECT net.http_post(
url := %L,
url := get_supabase_edge_function_url('send-pack-change-notification'),
headers := jsonb_build_object('Content-Type', 'application/json'),
body := '{}'::jsonb
)
$cmd$,
get_supabase_edge_function_url('send-pack-change-notification')
)
$cmd$
);
SELECT cron.schedule(
'send-partner-activity-notifications',
'*/5 * * * *',
format(
$cmd$
$cmd$
SELECT net.http_post(
url := %L,
url := get_supabase_edge_function_url('send-partner-activity-notification'),
headers := jsonb_build_object('Content-Type', 'application/json'),
body := '{}'::jsonb
)
$cmd$,
get_supabase_edge_function_url('send-partner-activity-notification')
)
$cmd$
);
SELECT cron.schedule(
'process-scheduled-releases',
'*/5 * * * *',
format(
$cmd$
$cmd$
SELECT net.http_post(
url := %L,
url := get_supabase_edge_function_url('process-scheduled-releases'),
headers := jsonb_build_object('Content-Type', 'application/json'),
body := '{}'::jsonb
)
$cmd$,
get_supabase_edge_function_url('process-scheduled-releases')
)
$cmd$
);
SELECT cron.schedule(
'check-streak-milestones',
'5 0 * * *',
format(
$cmd$
$cmd$
SELECT net.http_post(
url := %L,
url := get_supabase_edge_function_url('check-streak-milestones'),
headers := jsonb_build_object('Content-Type', 'application/json'),
body := '{}'::jsonb
)
$cmd$,
get_supabase_edge_function_url('check-streak-milestones')
)
$cmd$
);
SELECT cron.schedule(
'send-match-notification-digests',
'* * * * *',
format(
$cmd$
$cmd$
SELECT net.http_post(
url := %L,
url := get_supabase_edge_function_url('send-match-notification-digest'),
headers := jsonb_build_object('Content-Type', 'application/json'),
body := '{}'::jsonb
)
$cmd$,
get_supabase_edge_function_url('send-match-notification-digest')
)
$cmd$
);
SELECT cron.schedule(
'send-weekly-summary',
'0 10 * * 0',
format(
$cmd$
$cmd$
SELECT net.http_post(
url := %L,
url := get_supabase_edge_function_url('send-weekly-summary'),
headers := jsonb_build_object('Content-Type', 'application/json'),
body := '{}'::jsonb
)
$cmd$,
get_supabase_edge_function_url('send-weekly-summary')
)
$cmd$
);
SELECT cron.schedule(
'send-unpaired-reminders',
'0 18 * * *',
format(
$cmd$
$cmd$
SELECT net.http_post(
url := %L,
url := get_supabase_edge_function_url('send-unpaired-reminder'),
headers := jsonb_build_object('Content-Type', 'application/json'),
body := '{}'::jsonb
)
$cmd$,
get_supabase_edge_function_url('send-unpaired-reminder')
)
$cmd$
);
SELECT cron.schedule(
'send-catchup-reminders',
'0 17 * * *',
format(
$cmd$
$cmd$
SELECT net.http_post(
url := %L,
url := get_supabase_edge_function_url('send-catchup-reminder'),
headers := jsonb_build_object('Content-Type', 'application/json'),
body := '{}'::jsonb
)
$cmd$,
get_supabase_edge_function_url('send-catchup-reminder')
)
$cmd$
);
@@ -0,0 +1,4 @@
-- Notification triggers and scheduled jobs call net.http_post.
-- Keep the extension in migrations so clean local and preview databases have
-- the same dependency as hosted environments.
CREATE EXTENSION IF NOT EXISTS pg_net WITH SCHEMA extensions;
@@ -0,0 +1,35 @@
-- A production schema sync replaced the signup trigger function without
-- copying auth.users.email. Redemption by email and admin user lookup both
-- depend on profiles.email being populated.
CREATE OR REPLACE FUNCTION public.handle_new_user()
RETURNS trigger
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path TO ''
AS $function$
BEGIN
INSERT INTO public.profiles (
id,
email,
created_at,
updated_at,
onboarding_completed
)
VALUES (
NEW.id,
NEW.email,
NOW(),
NOW(),
false
);
RETURN NEW;
END;
$function$;
UPDATE public.profiles AS profile
SET email = auth_user.email,
updated_at = NOW()
FROM auth.users AS auth_user
WHERE profile.id = auth_user.id
AND profile.email IS NULL
AND auth_user.email IS NOT NULL;
@@ -0,0 +1,411 @@
-- Add reversible catalogue review metadata without changing customer visibility.
--
-- IMPORTANT: Current mobile/API queries intentionally do not filter content_status.
-- This migration only prepares and audits the catalogue for a later, separately
-- verified enforcement release.
CREATE TYPE public.content_review_status AS ENUM (
'unreviewed',
'allowed',
'archived'
);
CREATE TYPE public.content_entity_type AS ENUM (
'categories',
'question_packs',
'questions',
'dare_packs',
'dares'
);
ALTER TABLE public.categories
ADD COLUMN content_status public.content_review_status NOT NULL DEFAULT 'unreviewed',
ADD COLUMN content_review_reason TEXT,
ADD COLUMN content_reviewed_at TIMESTAMPTZ,
ADD COLUMN content_reviewed_by UUID REFERENCES auth.users(id) ON DELETE SET NULL;
ALTER TABLE public.question_packs
ADD COLUMN content_status public.content_review_status NOT NULL DEFAULT 'unreviewed',
ADD COLUMN content_review_reason TEXT,
ADD COLUMN content_reviewed_at TIMESTAMPTZ,
ADD COLUMN content_reviewed_by UUID REFERENCES auth.users(id) ON DELETE SET NULL;
ALTER TABLE public.questions
ADD COLUMN content_status public.content_review_status NOT NULL DEFAULT 'unreviewed',
ADD COLUMN content_review_reason TEXT,
ADD COLUMN content_reviewed_at TIMESTAMPTZ,
ADD COLUMN content_reviewed_by UUID REFERENCES auth.users(id) ON DELETE SET NULL;
ALTER TABLE public.dare_packs
ADD COLUMN content_status public.content_review_status NOT NULL DEFAULT 'unreviewed',
ADD COLUMN content_review_reason TEXT,
ADD COLUMN content_reviewed_at TIMESTAMPTZ,
ADD COLUMN content_reviewed_by UUID REFERENCES auth.users(id) ON DELETE SET NULL;
ALTER TABLE public.dares
ADD COLUMN content_status public.content_review_status NOT NULL DEFAULT 'unreviewed',
ADD COLUMN content_review_reason TEXT,
ADD COLUMN content_reviewed_at TIMESTAMPTZ,
ADD COLUMN content_reviewed_by UUID REFERENCES auth.users(id) ON DELETE SET NULL;
COMMENT ON COLUMN public.categories.content_status IS
'Editorial review metadata only. Customer visibility does not yet filter this column.';
COMMENT ON COLUMN public.question_packs.content_status IS
'Editorial review metadata only. Customer visibility does not yet filter this column.';
COMMENT ON COLUMN public.questions.content_status IS
'Editorial review metadata only. Customer visibility does not yet filter this column.';
COMMENT ON COLUMN public.dare_packs.content_status IS
'Editorial review metadata only. Customer visibility does not yet filter this column.';
COMMENT ON COLUMN public.dares.content_status IS
'Editorial review metadata only. Customer visibility does not yet filter this column.';
CREATE INDEX categories_content_status_idx
ON public.categories (content_status, sort_order);
CREATE INDEX question_packs_content_status_idx
ON public.question_packs (content_status, is_public, category_id, sort_order);
CREATE INDEX questions_content_status_idx
ON public.questions (content_status, pack_id, intensity)
WHERE deleted_at IS NULL;
CREATE INDEX dare_packs_content_status_idx
ON public.dare_packs (content_status, is_public, category_id, sort_order);
CREATE INDEX dares_content_status_idx
ON public.dares (content_status, pack_id, intensity);
CREATE TABLE public.content_reviews (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
entity_type public.content_entity_type NOT NULL,
entity_id UUID NOT NULL,
previous_status public.content_review_status NOT NULL,
new_status public.content_review_status NOT NULL,
reason TEXT NOT NULL CHECK (length(btrim(reason)) > 0),
changed_by UUID REFERENCES auth.users(id) ON DELETE SET NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
COMMENT ON TABLE public.content_reviews IS
'Append-only audit history for reversible catalogue review decisions.';
CREATE INDEX content_reviews_entity_idx
ON public.content_reviews (entity_type, entity_id, created_at DESC);
CREATE INDEX content_reviews_created_at_idx
ON public.content_reviews (created_at DESC);
ALTER TABLE public.content_reviews ENABLE ROW LEVEL SECURITY;
-- Supabase grants broad privileges to API roles through default privileges.
-- Remove them before restoring the one intended authenticated capability.
REVOKE ALL PRIVILEGES ON TABLE public.content_reviews FROM anon, authenticated;
CREATE POLICY "Super admins can view content review history"
ON public.content_reviews
FOR SELECT
TO authenticated
USING (public.is_super_admin());
GRANT SELECT ON public.content_reviews TO authenticated;
GRANT ALL ON public.content_reviews TO service_role;
CREATE OR REPLACE FUNCTION public.prepare_new_content_review()
RETURNS TRIGGER
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public, pg_temp
AS $$
BEGIN
-- Every new catalogue row enters the review queue. Callers cannot combine
-- creation and approval or forge reviewer metadata in one INSERT.
NEW.content_status := 'unreviewed';
NEW.content_review_reason := NULL;
NEW.content_reviewed_at := NULL;
NEW.content_reviewed_by := NULL;
RETURN NEW;
END;
$$;
CREATE OR REPLACE FUNCTION public.prepare_content_review_change()
RETURNS TRIGGER
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public, pg_temp
AS $$
DECLARE
visible_column TEXT;
visible_content_changed BOOLEAN := FALSE;
explicit_status_change BOOLEAN :=
NEW.content_status IS DISTINCT FROM OLD.content_status;
explicit_reason_change BOOLEAN :=
NEW.content_review_reason IS DISTINCT FROM OLD.content_review_reason;
explicit_reviewer_metadata_change BOOLEAN :=
NEW.content_reviewed_at IS DISTINCT FROM OLD.content_reviewed_at
OR NEW.content_reviewed_by IS DISTINCT FROM OLD.content_reviewed_by;
BEGIN
FOREACH visible_column IN ARRAY TG_ARGV LOOP
IF (to_jsonb(NEW) -> visible_column) IS DISTINCT FROM
(to_jsonb(OLD) -> visible_column) THEN
visible_content_changed := TRUE;
EXIT;
END IF;
END LOOP;
-- An explicit status/reason update is a review decision, including a
-- same-status re-review with a new reason. Reviewer identity and time are
-- always derived here rather than trusted from client input.
IF explicit_status_change OR explicit_reason_change THEN
IF auth.role() = 'anon'
OR (auth.uid() IS NOT NULL AND NOT public.is_super_admin()) THEN
RAISE EXCEPTION 'Only super admins can make catalogue review decisions';
END IF;
IF explicit_status_change AND NOT explicit_reason_change THEN
RAISE EXCEPTION 'A new content_review_reason is required when content_status changes';
END IF;
IF NULLIF(btrim(NEW.content_review_reason), '') IS NULL THEN
RAISE EXCEPTION 'A non-blank content_review_reason is required when content_status changes';
END IF;
IF NEW.content_status = 'unreviewed' THEN
NEW.content_reviewed_at := NULL;
NEW.content_reviewed_by := NULL;
ELSE
NEW.content_reviewed_at := now();
NEW.content_reviewed_by := auth.uid();
END IF;
-- A material content edit without an explicit review decision invalidates
-- the prior decision. This path is available to ordinary content editors.
ELSIF visible_content_changed THEN
NEW.content_status := 'unreviewed';
NEW.content_review_reason := 'Visible content changed; review required';
NEW.content_reviewed_at := NULL;
NEW.content_reviewed_by := NULL;
ELSIF explicit_reviewer_metadata_change THEN
-- Permit only the database-internal ON DELETE SET NULL action from the
-- auth.users foreign key. JWT-backed anon/service calls have an auth role;
-- direct client attempts therefore cannot use this exception.
IF auth.uid() IS NULL
AND auth.role() IS NULL
AND NEW.content_reviewed_by IS NULL
AND NEW.content_reviewed_at IS NOT DISTINCT FROM OLD.content_reviewed_at THEN
NULL;
ELSE
RAISE EXCEPTION 'content_reviewed_at and content_reviewed_by are database-managed';
END IF;
END IF;
RETURN NEW;
END;
$$;
CREATE OR REPLACE FUNCTION public.audit_content_review_change()
RETURNS TRIGGER
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public, pg_temp
AS $$
BEGIN
IF NEW.content_status IS DISTINCT FROM OLD.content_status
OR NEW.content_review_reason IS DISTINCT FROM OLD.content_review_reason THEN
INSERT INTO public.content_reviews (
entity_type,
entity_id,
previous_status,
new_status,
reason,
changed_by
) VALUES (
TG_TABLE_NAME::public.content_entity_type,
NEW.id,
OLD.content_status,
NEW.content_status,
NEW.content_review_reason,
auth.uid()
);
END IF;
RETURN NEW;
END;
$$;
-- These functions are trigger-only. Revoking direct execution keeps their
-- SECURITY DEFINER privileges unavailable as a general authenticated API.
REVOKE ALL ON FUNCTION public.prepare_content_review_change() FROM PUBLIC;
REVOKE ALL ON FUNCTION public.audit_content_review_change() FROM PUBLIC;
REVOKE ALL ON FUNCTION public.prepare_new_content_review() FROM PUBLIC;
REVOKE ALL ON FUNCTION public.prepare_content_review_change() FROM anon, authenticated;
REVOKE ALL ON FUNCTION public.audit_content_review_change() FROM anon, authenticated;
REVOKE ALL ON FUNCTION public.prepare_new_content_review() FROM anon, authenticated;
CREATE TRIGGER categories_prepare_new_content_review
BEFORE INSERT ON public.categories
FOR EACH ROW
EXECUTE FUNCTION public.prepare_new_content_review();
CREATE TRIGGER question_packs_prepare_new_content_review
BEFORE INSERT ON public.question_packs
FOR EACH ROW
EXECUTE FUNCTION public.prepare_new_content_review();
CREATE TRIGGER questions_prepare_new_content_review
BEFORE INSERT ON public.questions
FOR EACH ROW
EXECUTE FUNCTION public.prepare_new_content_review();
CREATE TRIGGER dare_packs_prepare_new_content_review
BEFORE INSERT ON public.dare_packs
FOR EACH ROW
EXECUTE FUNCTION public.prepare_new_content_review();
CREATE TRIGGER dares_prepare_new_content_review
BEFORE INSERT ON public.dares
FOR EACH ROW
EXECUTE FUNCTION public.prepare_new_content_review();
CREATE TRIGGER categories_prepare_content_review
BEFORE UPDATE ON public.categories
FOR EACH ROW
EXECUTE FUNCTION public.prepare_content_review_change(
'name', 'description', 'icon', 'color'
);
CREATE TRIGGER categories_audit_content_review
AFTER UPDATE ON public.categories
FOR EACH ROW
EXECUTE FUNCTION public.audit_content_review_change();
CREATE TRIGGER question_packs_prepare_content_review
BEFORE UPDATE ON public.question_packs
FOR EACH ROW
EXECUTE FUNCTION public.prepare_content_review_change(
'name', 'description', 'icon', 'category_id', 'is_explicit'
);
CREATE TRIGGER question_packs_audit_content_review
AFTER UPDATE ON public.question_packs
FOR EACH ROW
EXECUTE FUNCTION public.audit_content_review_change();
CREATE TRIGGER questions_prepare_content_review
BEFORE UPDATE ON public.questions
FOR EACH ROW
EXECUTE FUNCTION public.prepare_content_review_change(
'text',
'partner_text',
'pack_id',
'intensity',
'question_type',
'config',
'required_props',
'allowed_couple_genders',
'target_user_genders',
'inverse_of',
'deleted_at'
);
CREATE TRIGGER questions_audit_content_review
AFTER UPDATE ON public.questions
FOR EACH ROW
EXECUTE FUNCTION public.audit_content_review_change();
CREATE TRIGGER dare_packs_prepare_content_review
BEFORE UPDATE ON public.dare_packs
FOR EACH ROW
EXECUTE FUNCTION public.prepare_content_review_change(
'name', 'description', 'icon', 'category_id', 'is_explicit'
);
CREATE TRIGGER dare_packs_audit_content_review
AFTER UPDATE ON public.dare_packs
FOR EACH ROW
EXECUTE FUNCTION public.audit_content_review_change();
CREATE TRIGGER dares_prepare_content_review
BEFORE UPDATE ON public.dares
FOR EACH ROW
EXECUTE FUNCTION public.prepare_content_review_change(
'text',
'pack_id',
'intensity',
'suggested_duration_hours'
);
CREATE TRIGGER dares_audit_content_review
AFTER UPDATE ON public.dares
FOR EACH ROW
EXECUTE FUNCTION public.audit_content_review_change();
-- Stage the conservative archive set by immutable production IDs. This does not
-- change is_public, deleted_at, RLS, RPC selection, or current application output.
WITH archive_packs(id) AS (
VALUES
('9049a67a-a103-4ea3-8405-87dcaefc57ab'::UUID),
('3c17764d-dd11-4103-8a4f-ce6561fd2a1a'::UUID),
('f0a2e7e5-554c-49c4-aa15-42d83b016349'::UUID),
('d2000000-0000-0000-0000-000000000003'::UUID),
('40838dee-0af4-43c7-9d6c-6228d09f91bc'::UUID),
('d2000000-0000-0000-0000-000000000002'::UUID),
('44295bd5-fda3-4ed6-8aa6-d5c7e811348e'::UUID),
('c7244576-f40d-4cdf-804a-788d2b9c1fce'::UUID),
('7eabdfae-e70d-4be8-9b10-b43fee955312'::UUID),
('374e10a6-6ae9-45c6-bee4-b8be0e617b79'::UUID),
('dd143c55-c0a4-4884-a704-1d4d1325cbc2'::UUID),
('035c47a6-858c-46fb-8ee3-84889399dca0'::UUID),
('8bf628d0-2a83-4a12-a66b-39b0d6ff1e86'::UUID),
('42331f68-60f5-4768-af26-b42c4e96c867'::UUID),
('379d6a44-60a3-4ec9-98cc-eda46547cb23'::UUID),
('e5aefe29-1ada-4fe5-a4ee-25342917f02c'::UUID),
('bed94ed6-c065-4ff9-9d7f-02de6cade971'::UUID),
('eb3cbe67-23d8-4c0e-9c6f-dc1583609a26'::UUID),
('d18f6d4a-4a32-4687-8938-2a53a7d82739'::UUID),
('b2df6b3b-4bb5-4baa-a29c-a3955187511c'::UUID),
('d73e9818-4faa-4276-9a93-7c60cff9cdb1'::UUID),
('7951e8c4-c7d4-4460-a3f5-495ccf5ca0cb'::UUID),
('f0f597b1-889c-4537-beeb-d0f459bb7907'::UUID),
('46288aa4-271c-44cf-bfe2-d25662d3f9f8'::UUID),
('d2000000-0000-0000-0000-000000000001'::UUID),
('07f0da41-8ef7-4a48-8ec9-cef2dabaee73'::UUID),
('42c00d6c-ab1f-42ce-afb0-00839064263b'::UUID),
('8a1e8650-a192-4f2e-86b0-0ec7bf6fb61c'::UUID),
('d5d1b8d2-a868-4150-86bc-da3f8e3e8b00'::UUID),
('19e4ddbd-a4aa-4b26-80bb-53b7187f0eba'::UUID)
)
UPDATE public.question_packs qp
SET
content_status = 'archived',
content_review_reason = 'Conservative archive candidate following the 2026-02-03 store rejection',
content_reviewed_at = now(),
content_reviewed_by = NULL
FROM archive_packs ap
WHERE qp.id = ap.id;
WITH archive_packs(id) AS (
VALUES
('9049a67a-a103-4ea3-8405-87dcaefc57ab'::UUID),
('3c17764d-dd11-4103-8a4f-ce6561fd2a1a'::UUID),
('f0a2e7e5-554c-49c4-aa15-42d83b016349'::UUID),
('d2000000-0000-0000-0000-000000000003'::UUID),
('40838dee-0af4-43c7-9d6c-6228d09f91bc'::UUID),
('d2000000-0000-0000-0000-000000000002'::UUID),
('44295bd5-fda3-4ed6-8aa6-d5c7e811348e'::UUID),
('c7244576-f40d-4cdf-804a-788d2b9c1fce'::UUID),
('7eabdfae-e70d-4be8-9b10-b43fee955312'::UUID),
('374e10a6-6ae9-45c6-bee4-b8be0e617b79'::UUID),
('dd143c55-c0a4-4884-a704-1d4d1325cbc2'::UUID),
('035c47a6-858c-46fb-8ee3-84889399dca0'::UUID),
('8bf628d0-2a83-4a12-a66b-39b0d6ff1e86'::UUID),
('42331f68-60f5-4768-af26-b42c4e96c867'::UUID),
('379d6a44-60a3-4ec9-98cc-eda46547cb23'::UUID),
('e5aefe29-1ada-4fe5-a4ee-25342917f02c'::UUID),
('bed94ed6-c065-4ff9-9d7f-02de6cade971'::UUID),
('eb3cbe67-23d8-4c0e-9c6f-dc1583609a26'::UUID),
('d18f6d4a-4a32-4687-8938-2a53a7d82739'::UUID),
('b2df6b3b-4bb5-4baa-a29c-a3955187511c'::UUID),
('d73e9818-4faa-4276-9a93-7c60cff9cdb1'::UUID),
('7951e8c4-c7d4-4460-a3f5-495ccf5ca0cb'::UUID),
('f0f597b1-889c-4537-beeb-d0f459bb7907'::UUID),
('46288aa4-271c-44cf-bfe2-d25662d3f9f8'::UUID),
('d2000000-0000-0000-0000-000000000001'::UUID),
('07f0da41-8ef7-4a48-8ec9-cef2dabaee73'::UUID),
('42c00d6c-ab1f-42ce-afb0-00839064263b'::UUID),
('8a1e8650-a192-4f2e-86b0-0ec7bf6fb61c'::UUID),
('d5d1b8d2-a868-4150-86bc-da3f8e3e8b00'::UUID),
('19e4ddbd-a4aa-4b26-80bb-53b7187f0eba'::UUID)
)
UPDATE public.questions q
SET
content_status = 'archived',
content_review_reason = 'Archived with its pack pending rewrite',
content_reviewed_at = now(),
content_reviewed_by = NULL
FROM archive_packs ap
WHERE q.pack_id = ap.id;
@@ -0,0 +1,407 @@
-- Enforce the reviewed catalogue centrally so already-installed mobile clients
-- cannot retrieve unreviewed or archived curated content. Admin SELECT policies
-- remain separate and continue to expose the full review queue to administrators.
-- Only neutral category labels which contain reviewed relationship/date packs
-- are exposed. Categories containing only archived material are archived too.
-- Unknown rows are deliberately archived, making concurrent additions fail
-- closed without coupling this portable migration to a remote environment.
UPDATE public.categories
SET
content_status = CASE
WHEN id IN (
'c1000000-0000-0000-0000-000000000002'::uuid, -- Adventure & Travel
'fcb36b61-6081-4bf6-9267-1ba9ba75fc08'::uuid, -- Quality Time
'43106c46-042a-4032-a663-8e8206273927'::uuid, -- Who is More Likely
'ef425e8d-9fc4-40c2-a5e7-5fc4cb6b64da'::uuid, -- Social Life
'ecabc509-9fed-48cb-bd60-c5d830d2491d'::uuid -- Long Distance
) THEN 'allowed'::public.content_review_status
ELSE 'archived'::public.content_review_status
END,
content_review_reason = CASE
WHEN id IN (
'c1000000-0000-0000-0000-000000000002'::uuid,
'fcb36b61-6081-4bf6-9267-1ba9ba75fc08'::uuid,
'43106c46-042a-4032-a663-8e8206273927'::uuid,
'ef425e8d-9fc4-40c2-a5e7-5fc4cb6b64da'::uuid,
'ecabc509-9fed-48cb-bd60-c5d830d2491d'::uuid
) THEN 'Reviewed neutral category for store-safe relationship content'
ELSE 'Category contains no store-safe reviewed packs'
END;
-- The fourteen reviewed non-explicit relationship/date packs are allowed.
-- Three explicit packs missed by the first conservative pass are archived.
WITH reviewed_allowed_packs(id) AS (
VALUES
('7e9cb72e-95dd-4140-8d58-17206e6fe153'::uuid), -- Between the Lines
('8da8dcb1-0b9b-4828-9a30-199758b8ade5'::uuid), -- Date Night Ideas
('e11bdfea-ab07-46a9-952d-5c00c8b4ddbb'::uuid), -- Everyday Moments
('cf483ec0-502a-41ea-a2a2-2f223e7ec6ce'::uuid), -- Everyday Us
('877d8a39-5229-4263-bffc-d3043015603f'::uuid), -- Finding Your Vibe
('1d2ed912-1429-4045-a608-35b8db63f9b4'::uuid), -- Food & Culture
('45ff272c-be6d-4faa-a932-a5285a19afe6'::uuid), -- Growing Together
('d6128ed8-e209-43db-88eb-e9d0115ba8d7'::uuid), -- Hosting
('1ec3f726-b495-423b-a5dc-60f5178fdf21'::uuid), -- Our Future
('0f2b3400-05d4-40db-b8fa-41c92ab877eb'::uuid), -- Outdoor Explorers
('48150d71-bf95-49b0-924f-e9bcff63fc03'::uuid), -- Relationship Essentials
('fd6f2a49-1057-4f30-aab4-7cd185764f7e'::uuid), -- Staying Close
('aea8bb8f-de02-41ad-bd1f-7616780dde30'::uuid), -- Weekend Adventures (empty)
('91dacfed-46ae-462d-aae3-5e540fe4a973'::uuid) -- Weekend Warriors
)
UPDATE public.question_packs qp
SET
content_status = CASE
WHEN rap.id IS NOT NULL THEN 'allowed'::public.content_review_status
ELSE 'archived'::public.content_review_status
END,
content_review_reason = CASE
WHEN rap.id IS NOT NULL
THEN 'Reviewed as non-explicit relationship, conversation, or date content'
ELSE 'Explicit pack identified during final enforcement review'
END
FROM (SELECT id FROM public.question_packs) pending
LEFT JOIN reviewed_allowed_packs rap ON rap.id = pending.id
WHERE qp.id = pending.id;
-- Every question inherits its reviewed pack decision except one suggestive
-- shower prompt, which is archived individually from an otherwise safe pack.
UPDATE public.questions q
SET
content_status = CASE
WHEN qp.content_status = 'allowed'
AND q.id <> '8d49681a-4b27-445e-9fda-915a3a7d7f5c'::uuid
THEN 'allowed'::public.content_review_status
ELSE 'archived'::public.content_review_status
END,
content_review_reason = CASE
WHEN qp.content_status = 'allowed'
AND q.id <> '8d49681a-4b27-445e-9fda-915a3a7d7f5c'::uuid
THEN 'Reviewed with its store-safe relationship pack'
WHEN q.id = '8d49681a-4b27-445e-9fda-915a3a7d7f5c'::uuid
THEN 'Suggestive shower prompt archived during final enforcement review'
ELSE 'Archived with explicit pack during final enforcement review'
END
FROM public.question_packs qp
WHERE q.pack_id = qp.id;
-- Keep only the plainly romantic dare pack. The remaining packs contain sexual,
-- suggestive, dominance, or fantasy prompts and are archived as complete packs.
UPDATE public.dare_packs
SET
content_status = CASE
WHEN id = 'a1b2c3d4-1111-1111-1111-111111111111'::uuid
THEN 'allowed'::public.content_review_status
ELSE 'archived'::public.content_review_status
END,
content_review_reason = CASE
WHEN id = 'a1b2c3d4-1111-1111-1111-111111111111'::uuid
THEN 'Reviewed as non-sexual romantic gestures'
ELSE 'Pack contains suggestive, sexual, dominance, or fantasy dares'
END;
UPDATE public.dares d
SET
content_status = CASE
WHEN dp.content_status = 'allowed' THEN 'allowed'::public.content_review_status
ELSE 'archived'::public.content_review_status
END,
content_review_reason = CASE
WHEN dp.content_status = 'allowed' THEN 'Reviewed with Romantic Gestures pack'
ELSE 'Archived with non-store-safe dare pack'
END
FROM public.dare_packs dp
WHERE d.pack_id = dp.id;
DO $$
BEGIN
IF EXISTS (SELECT 1 FROM public.categories WHERE content_status = 'unreviewed')
OR EXISTS (SELECT 1 FROM public.question_packs WHERE content_status = 'unreviewed')
OR EXISTS (SELECT 1 FROM public.questions WHERE content_status = 'unreviewed')
OR EXISTS (SELECT 1 FROM public.dare_packs WHERE content_status = 'unreviewed')
OR EXISTS (SELECT 1 FROM public.dares WHERE content_status = 'unreviewed')
OR EXISTS (
SELECT 1 FROM public.question_packs qp
LEFT JOIN public.categories c ON c.id = qp.category_id
WHERE qp.content_status = 'allowed'
AND qp.category_id IS NOT NULL
AND c.content_status <> 'allowed'
)
OR EXISTS (
SELECT 1 FROM public.questions q
JOIN public.question_packs qp ON qp.id = q.pack_id
WHERE q.content_status = 'allowed' AND qp.content_status <> 'allowed'
) THEN
RAISE EXCEPTION 'Catalogue enforcement left an unreviewed or invalid hierarchy';
END IF;
END;
$$;
-- Remove archived packs from existing couples' active recommendation sets.
-- Suppress the ordinary partner-notification trigger because this is a global
-- policy cutover, not a user-initiated pack preference change.
CREATE TEMP TABLE catalogue_cutover_couples ON COMMIT DROP AS
SELECT DISTINCT cp.couple_id
FROM public.couple_packs cp
JOIN public.question_packs qp ON qp.id = cp.pack_id
WHERE cp.enabled = true
AND qp.content_status <> 'allowed'
AND NOT EXISTS (
SELECT 1
FROM public.couple_packs enabled_cp
JOIN public.question_packs enabled_qp ON enabled_qp.id = enabled_cp.pack_id
WHERE enabled_cp.couple_id = cp.couple_id
AND enabled_cp.enabled = true
AND enabled_qp.content_status = 'allowed'
);
ALTER TABLE public.couple_packs DISABLE TRIGGER on_couple_pack_enabled;
UPDATE public.couple_packs cp
SET enabled = false
FROM public.question_packs qp
WHERE qp.id = cp.pack_id
AND qp.content_status <> 'allowed'
AND cp.enabled = true;
-- If the cutover removed a couple's entire active selection, seed the same
-- reviewed low-intensity defaults used for newly-created couples. Existing
-- allowed selections and intentional all-disabled configurations are untouched.
INSERT INTO public.couple_packs (couple_id, pack_id, enabled)
SELECT affected.couple_id, qp.id, true
FROM catalogue_cutover_couples affected
CROSS JOIN public.question_packs qp
WHERE qp.content_status = 'allowed'
AND qp.is_public = true
AND qp.avg_intensity <= 2
ON CONFLICT (couple_id, pack_id)
DO UPDATE SET enabled = EXCLUDED.enabled;
ALTER TABLE public.couple_packs ENABLE TRIGGER on_couple_pack_enabled;
-- RLS is the compatibility boundary for direct table queries made by existing
-- clients. Permissive admin policies remain in place and still expose all rows.
DROP POLICY IF EXISTS "Categories are viewable by everyone" ON public.categories;
CREATE POLICY "Categories are viewable by everyone"
ON public.categories FOR SELECT
USING (is_public = true AND content_status = 'allowed');
DROP POLICY IF EXISTS "Anyone can view public packs" ON public.question_packs;
CREATE POLICY "Anyone can view public packs"
ON public.question_packs FOR SELECT
USING (
is_public = true
AND content_status = 'allowed'
AND (
category_id IS NULL
OR EXISTS (
SELECT 1 FROM public.categories c
WHERE c.id = question_packs.category_id
AND c.is_public = true
AND c.content_status = 'allowed'
)
)
);
-- This older permissive policy made free non-public packs readable and is
-- redundant with the public-pack policy for catalogue metadata.
DROP POLICY IF EXISTS "Premium users can view premium packs" ON public.question_packs;
DROP POLICY IF EXISTS "Anyone can view questions in visible packs" ON public.questions;
CREATE POLICY "Anyone can view questions in visible packs"
ON public.questions FOR SELECT
USING (
content_status = 'allowed'
AND EXISTS (
SELECT 1
FROM public.question_packs qp
WHERE qp.id = questions.pack_id
AND qp.is_public = true
AND qp.content_status = 'allowed'
AND (
qp.category_id IS NULL
OR EXISTS (
SELECT 1 FROM public.categories c
WHERE c.id = qp.category_id
AND c.is_public = true
AND c.content_status = 'allowed'
)
)
)
);
DROP POLICY IF EXISTS "Public dare packs are viewable by everyone" ON public.dare_packs;
CREATE POLICY "Public dare packs are viewable by everyone"
ON public.dare_packs FOR SELECT
USING (
is_public = true
AND content_status = 'allowed'
AND (
category_id IS NULL
OR EXISTS (
SELECT 1 FROM public.categories c
WHERE c.id = dare_packs.category_id
AND c.is_public = true
AND c.content_status = 'allowed'
)
)
);
DROP POLICY IF EXISTS "Users can view dares in accessible packs" ON public.dares;
CREATE POLICY "Users can view dares in accessible packs"
ON public.dares FOR SELECT
USING (
content_status = 'allowed'
AND EXISTS (
SELECT 1 FROM public.dare_packs dp
WHERE dp.id = dares.pack_id
AND dp.is_public = true
AND dp.content_status = 'allowed'
AND (
dp.is_premium = false
OR EXISTS (
SELECT 1 FROM public.profiles p
WHERE p.id = auth.uid() AND p.is_premium = true
)
)
AND (
dp.category_id IS NULL
OR EXISTS (
SELECT 1 FROM public.categories c
WHERE c.id = dp.category_id
AND c.is_public = true
AND c.content_status = 'allowed'
)
)
)
);
-- Hide historical match rows whose curated source question is no longer
-- eligible. Existing mobile queries use a left relationship, so filtering only
-- the nested question would otherwise leave a null-content match card behind.
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 EXISTS (
SELECT 1
FROM public.questions q
JOIN public.question_packs qp ON qp.id = q.pack_id
LEFT JOIN public.categories c ON c.id = qp.category_id
WHERE q.id = matches.question_id
AND q.content_status = 'allowed'
AND qp.content_status = 'allowed'
AND (qp.category_id IS NULL OR c.content_status = 'allowed')
)
);
-- Existing clients may write couple_packs directly. Prevent a stale client from
-- re-enabling an archived pack, while preserving deletion of old preference rows.
DROP POLICY IF EXISTS "Couples can view their own pack settings" ON public.couple_packs;
CREATE POLICY "Couples can view their own pack settings"
ON public.couple_packs FOR SELECT
USING (
EXISTS (
SELECT 1 FROM public.profiles p
WHERE p.id = auth.uid() AND p.couple_id = couple_packs.couple_id
)
AND EXISTS (
SELECT 1 FROM public.question_packs qp
WHERE qp.id = couple_packs.pack_id AND qp.content_status = 'allowed'
)
);
DROP POLICY IF EXISTS "Couples can modify their own pack settings" ON public.couple_packs;
CREATE POLICY "Couples can insert allowed pack settings"
ON public.couple_packs FOR INSERT
WITH CHECK (
EXISTS (
SELECT 1 FROM public.profiles p
WHERE p.id = auth.uid() AND p.couple_id = couple_packs.couple_id
)
AND EXISTS (
SELECT 1 FROM public.question_packs qp
WHERE qp.id = couple_packs.pack_id AND qp.content_status = 'allowed'
)
);
CREATE POLICY "Couples can update allowed pack settings"
ON public.couple_packs FOR UPDATE
USING (
EXISTS (
SELECT 1 FROM public.profiles p
WHERE p.id = auth.uid() AND p.couple_id = couple_packs.couple_id
)
)
WITH CHECK (
EXISTS (
SELECT 1 FROM public.profiles p
WHERE p.id = auth.uid() AND p.couple_id = couple_packs.couple_id
)
AND EXISTS (
SELECT 1 FROM public.question_packs qp
WHERE qp.id = couple_packs.pack_id AND qp.content_status = 'allowed'
)
);
-- Wrap SECURITY DEFINER catalogue RPCs rather than relying on RLS, which their
-- owner privileges bypass. The original implementations retain all recommendation
-- behavior but are no longer executable by API roles.
ALTER FUNCTION public.get_recommended_questions(uuid)
RENAME TO get_recommended_questions_before_content_enforcement;
REVOKE ALL ON FUNCTION public.get_recommended_questions_before_content_enforcement(uuid)
FROM PUBLIC, anon, authenticated;
CREATE FUNCTION public.get_recommended_questions(target_pack_id uuid DEFAULT NULL::uuid)
RETURNS TABLE(
id uuid,
text text,
partner_text text,
is_two_part boolean,
pack_id uuid,
intensity integer,
partner_answered boolean,
allowed_couple_genders text[],
target_user_genders text[],
question_type public.question_type,
config jsonb
)
LANGUAGE sql
STABLE
SECURITY DEFINER
SET search_path = public, pg_temp
AS $$
SELECT candidate.*
FROM public.get_recommended_questions_before_content_enforcement(target_pack_id) candidate
JOIN public.questions q ON q.id = candidate.id
JOIN public.question_packs qp ON qp.id = q.pack_id
LEFT JOIN public.categories c ON c.id = qp.category_id
WHERE q.content_status = 'allowed'
AND qp.content_status = 'allowed'
AND (qp.category_id IS NULL OR c.content_status = 'allowed');
$$;
REVOKE ALL ON FUNCTION public.get_recommended_questions(uuid) FROM PUBLIC;
GRANT EXECUTE ON FUNCTION public.get_recommended_questions(uuid) TO anon, authenticated, service_role;
ALTER FUNCTION public.get_pack_teaser_questions(uuid)
RENAME TO get_pack_teaser_questions_before_content_enforcement;
REVOKE ALL ON FUNCTION public.get_pack_teaser_questions_before_content_enforcement(uuid)
FROM PUBLIC, anon, authenticated;
CREATE FUNCTION public.get_pack_teaser_questions(target_pack_id uuid)
RETURNS TABLE(id uuid, text text, intensity integer)
LANGUAGE sql
STABLE
SECURITY DEFINER
SET search_path = public, pg_temp
AS $$
SELECT candidate.*
FROM public.get_pack_teaser_questions_before_content_enforcement(target_pack_id) candidate
JOIN public.questions q ON q.id = candidate.id
JOIN public.question_packs qp ON qp.id = q.pack_id
LEFT JOIN public.categories c ON c.id = qp.category_id
WHERE q.content_status = 'allowed'
AND qp.content_status = 'allowed'
AND (qp.category_id IS NULL OR c.content_status = 'allowed');
$$;
REVOKE ALL ON FUNCTION public.get_pack_teaser_questions(uuid) FROM PUBLIC;
GRANT EXECUTE ON FUNCTION public.get_pack_teaser_questions(uuid) TO anon, authenticated, service_role;
@@ -0,0 +1,30 @@
import { assertEquals } from "std/assert/mod.ts";
import {
canViewResponseMedia,
getResponseMediaDescriptor,
} from "../functions/admin-response-media/helpers.ts";
Deno.test("response media access requires the response permission", () => {
assertEquals(canViewResponseMedia("super_admin", null), true);
assertEquals(canViewResponseMedia("pack_creator", ["view_responses"]), true);
assertEquals(canViewResponseMedia("pack_creator", ["view_activity"]), false);
});
Deno.test("response media is derived from the stored response payload", () => {
assertEquals(
getResponseMediaDescriptor({
type: "photo",
media_path:
"https://example.supabase.co/storage/v1/object/sign/response-media/user/question.jpg?token=secret",
}),
{ type: "photo", path: "user/question.jpg" },
);
assertEquals(
getResponseMediaDescriptor({ type: "text_answer", text: "hello" }),
null,
);
assertEquals(
getResponseMediaDescriptor({ type: "audio", media_path: "" }),
null,
);
});
@@ -0,0 +1,669 @@
import { createClient } from "@supabase/supabase-js";
import { assert, assertEquals, assertMatch } from "std/assert/mod.ts";
import {
adminClient,
cleanup,
createCouple,
createTestUser,
SUPABASE_URL,
} from "./utils.ts";
const anonKey = Deno.env.get("SUPABASE_ANON_KEY");
if (!anonKey) {
throw new Error("SUPABASE_ANON_KEY is required");
}
Deno.test("catalogue review migration stages the complete audited archive set", async () => {
const migrationSql = await Deno.readTextFile(
new URL(
"../migrations/20260827215907_add_catalog_content_review_metadata.sql",
import.meta.url,
),
);
const archiveBlocks = [...migrationSql.matchAll(
/WITH archive_packs\(id\) AS \(\s*VALUES([\s\S]*?)\)\s*UPDATE/g,
)];
assertEquals(archiveBlocks.length, 2);
const extractIds = (block: string) =>
[...block.matchAll(
/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/g,
)].map(([id]) => id);
const packIds = extractIds(archiveBlocks[0][1]);
const questionPackIds = extractIds(archiveBlocks[1][1]);
assertEquals(packIds.length, 30);
assertEquals(new Set(packIds).size, 30);
assertEquals(questionPackIds, packIds);
for (
const auditedMixedPackId of [
"f0f597b1-889c-4537-beeb-d0f459bb7907", // Body Mapping
"46288aa4-271c-44cf-bfe2-d25662d3f9f8", // Date Night Sparks
"d2000000-0000-0000-0000-000000000001", // First Impressions
"07f0da41-8ef7-4a48-8ec9-cef2dabaee73", // Love Notes
"42c00d6c-ab1f-42ce-afb0-00839064263b", // Romantic Gestures
"8a1e8650-a192-4f2e-86b0-0ec7bf6fb61c", // Sensual Massage
"d5d1b8d2-a868-4150-86bc-da3f8e3e8b00", // Testing the Waters
"19e4ddbd-a4aa-4b26-80bb-53b7187f0eba", // Missing You
]
) {
assertEquals(packIds.includes(auditedMixedPackId), true);
}
});
Deno.test("catalogue enforcement exposes only reviewed allowed content to existing clients", async () => {
const suffix = crypto.randomUUID();
const email1 = `catalogue-enforcement-1-${suffix}@example.com`;
const email2 = `catalogue-enforcement-2-${suffix}@example.com`;
const userIds: string[] = [];
const entityIds: string[] = [];
let coupleId: string | undefined;
let categoryId: string | undefined;
let packIds: string[] = [];
let questionIds: string[] = [];
let darePackIds: string[] = [];
let dareIds: string[] = [];
try {
const { data: category, error: categoryInsertError } = await adminClient
.from("categories")
.insert({ name: `Enforcement category ${suffix}`, is_public: true })
.select("id")
.single();
if (categoryInsertError) throw categoryInsertError;
categoryId = category.id;
entityIds.push(category.id);
const { data: packs, error: packInsertError } = await adminClient
.from("question_packs")
.insert([
{
name: `Allowed pack ${suffix}`,
category_id: categoryId,
is_public: true,
},
{
name: `Archived pack ${suffix}`,
category_id: categoryId,
is_public: true,
},
{
name: `Unreviewed pack ${suffix}`,
category_id: categoryId,
is_public: true,
},
])
.select("id");
if (packInsertError) throw packInsertError;
packIds = packs.map((pack) => pack.id);
entityIds.push(...packIds);
const { data: questions, error: questionInsertError } = await adminClient
.from("questions")
.insert(packIds.map((packId, index) => ({
pack_id: packId,
text: `Enforcement question ${index} ${suffix}`,
intensity: 1,
})))
.select("id");
if (questionInsertError) throw questionInsertError;
questionIds = questions.map((question) => question.id);
entityIds.push(...questionIds);
const { data: darePacks, error: darePackInsertError } = await adminClient
.from("dare_packs")
.insert([
{
name: `Allowed dare pack ${suffix}`,
category_id: categoryId,
is_public: true,
},
{
name: `Archived dare pack ${suffix}`,
category_id: categoryId,
is_public: true,
},
])
.select("id");
if (darePackInsertError) throw darePackInsertError;
darePackIds = darePacks.map((pack) => pack.id);
entityIds.push(...darePackIds);
const { data: dares, error: dareInsertError } = await adminClient
.from("dares")
.insert(darePackIds.map((packId, index) => ({
pack_id: packId,
text: `Enforcement dare ${index} ${suffix}`,
intensity: 1,
})))
.select("id");
if (dareInsertError) throw dareInsertError;
dareIds = dares.map((dare) => dare.id);
entityIds.push(...dareIds);
const reviewResults = await Promise.all([
adminClient.from("categories").update({
content_status: "allowed",
content_review_reason: "Allowed fixture category",
}).eq("id", categoryId),
adminClient.from("question_packs").update({
content_status: "allowed",
content_review_reason: "Allowed fixture pack",
}).eq("id", packIds[0]),
adminClient.from("question_packs").update({
content_status: "archived",
content_review_reason: "Archived fixture pack",
}).eq("id", packIds[1]),
adminClient.from("questions").update({
content_status: "allowed",
content_review_reason: "Allowed fixture question",
}).eq("id", questionIds[0]),
adminClient.from("questions").update({
content_status: "archived",
content_review_reason: "Archived fixture question",
}).eq("id", questionIds[1]),
adminClient.from("dare_packs").update({
content_status: "allowed",
content_review_reason: "Allowed fixture dare pack",
}).eq("id", darePackIds[0]),
adminClient.from("dare_packs").update({
content_status: "archived",
content_review_reason: "Archived fixture dare pack",
}).eq("id", darePackIds[1]),
adminClient.from("dares").update({
content_status: "allowed",
content_review_reason: "Allowed fixture dare",
}).eq("id", dareIds[0]),
adminClient.from("dares").update({
content_status: "archived",
content_review_reason: "Archived fixture dare",
}).eq("id", dareIds[1]),
]);
for (const { error } of reviewResults) if (error) throw error;
const user1 = await createTestUser(email1);
const user2 = await createTestUser(email2);
userIds.push(user1.id, user2.id);
const couple = await createCouple(user1.id, user2.id);
coupleId = couple.id;
const { data: matches, error: matchInsertError } = await adminClient
.from("matches")
.insert([
{
couple_id: coupleId,
question_id: questionIds[0],
match_type: "yes_yes",
},
{
couple_id: coupleId,
question_id: questionIds[1],
match_type: "yes_yes",
},
])
.select("id, question_id");
if (matchInsertError) throw matchInsertError;
const userClient = createClient(SUPABASE_URL, anonKey, {
auth: { autoRefreshToken: false, persistSession: false },
});
const { error: signInError } = await userClient.auth.signInWithPassword({
email: email1,
password: "password123",
});
if (signInError) throw signInError;
const [
{ data: visiblePacks, error: packError },
{ data: visibleQuestions, error: questionError },
{ data: visibleDarePacks, error: darePackError },
{ data: visibleDares, error: dareError },
{ data: visibleMatches, error: matchError },
] = await Promise.all([
userClient.from("question_packs").select("id, content_status").in(
"id",
packIds,
),
userClient.from("questions").select("id, content_status").in(
"id",
questionIds,
),
userClient.from("dare_packs").select("id, content_status").in(
"id",
darePackIds,
),
userClient.from("dares").select("id, content_status").in("id", dareIds),
userClient.from("matches").select("id, question_id").in(
"id",
matches.map((match) => match.id),
),
]);
if (packError) throw packError;
if (questionError) throw questionError;
if (darePackError) throw darePackError;
if (dareError) throw dareError;
if (matchError) throw matchError;
assertEquals(visiblePacks.map((row) => row.id), [packIds[0]]);
assertEquals(visibleQuestions.map((row) => row.id), [questionIds[0]]);
assertEquals(visibleDarePacks.map((row) => row.id), [darePackIds[0]]);
assertEquals(visibleDares.map((row) => row.id), [dareIds[0]]);
assertEquals(visibleMatches.map((row) => row.question_id), [
questionIds[0],
]);
const allowedPackId = packIds[0];
const archivedPackId = packIds[1];
const {
data: archivedRecommendations,
error: archivedRecommendationError,
} = await userClient.rpc("get_recommended_questions", {
target_pack_id: archivedPackId,
});
if (archivedRecommendationError) throw archivedRecommendationError;
assertEquals(archivedRecommendations.length, 0);
const { data: allowedRecommendations, error: allowedRecommendationError } =
await userClient.rpc(
"get_recommended_questions",
{
target_pack_id: allowedPackId,
},
);
if (allowedRecommendationError) throw allowedRecommendationError;
assertEquals(allowedRecommendations.map((row: { id: string }) => row.id), [
questionIds[0],
]);
const [
{ data: archivedTeasers, error: archivedTeaserError },
{ data: allowedTeasers, error: allowedTeaserError },
] = await Promise.all([
userClient.rpc("get_pack_teaser_questions", {
target_pack_id: archivedPackId,
}),
userClient.rpc("get_pack_teaser_questions", {
target_pack_id: allowedPackId,
}),
]);
if (archivedTeaserError) throw archivedTeaserError;
if (allowedTeaserError) throw allowedTeaserError;
assertEquals(archivedTeasers.length, 0);
assertEquals(allowedTeasers.map((row: { id: string }) => row.id), [
questionIds[0],
]);
const { error: archivedPackEnableError } = await userClient
.from("couple_packs")
.insert({ couple_id: coupleId, pack_id: archivedPackId, enabled: true });
assert(archivedPackEnableError);
const { error: allowedPackEnableError } = await userClient
.from("couple_packs")
.insert({ couple_id: coupleId, pack_id: allowedPackId, enabled: true });
if (allowedPackEnableError) throw allowedPackEnableError;
} finally {
await cleanup(userIds, coupleId);
if (entityIds.length > 0) {
await adminClient.from("content_reviews").delete().in(
"entity_id",
entityIds,
);
}
if (questionIds.length > 0) {
await adminClient.from("questions").delete().in("id", questionIds);
}
if (packIds.length > 0) {
await adminClient.from("question_packs").delete().in("id", packIds);
}
if (dareIds.length > 0) {
await adminClient.from("dares").delete().in("id", dareIds);
}
if (darePackIds.length > 0) {
await adminClient.from("dare_packs").delete().in("id", darePackIds);
}
if (categoryId) {
await adminClient.from("categories").delete().eq("id", categoryId);
}
}
});
Deno.test("catalogue review metadata is reversible, audited, and non-super-admin decisions are rejected", async () => {
const suffix = crypto.randomUUID();
const email = `content-review-${suffix}@example.com`;
let packId: string | undefined;
let insertedPackId: string | undefined;
let userId: string | undefined;
try {
const { data: pack, error: packError } = await adminClient
.from("question_packs")
.insert({
name: `Content review test ${suffix}`,
is_public: true,
is_premium: false,
is_explicit: false,
})
.select("id, content_status")
.single();
if (packError) throw packError;
packId = pack.id;
assertEquals(pack.content_status, "unreviewed");
const { data: archived, error: archiveError } = await adminClient
.from("question_packs")
.update({
content_status: "archived",
content_review_reason: "Test archive decision",
})
.eq("id", packId)
.select("content_status, content_review_reason")
.single();
if (archiveError) throw archiveError;
assertEquals(archived.content_status, "archived");
assertEquals(archived.content_review_reason, "Test archive decision");
const { error: blankReasonError } = await adminClient
.from("question_packs")
.update({
content_status: "allowed",
content_review_reason: " ",
})
.eq("id", packId);
if (!blankReasonError) {
throw new Error("Expected blank review reason to be rejected");
}
assertMatch(blankReasonError.message, /non-blank content_review_reason/i);
const user = await createTestUser(email);
userId = user.id;
const userClient = createClient(SUPABASE_URL, anonKey, {
auth: { autoRefreshToken: false, persistSession: false },
});
const { error: signInError } = await userClient.auth.signInWithPassword({
email,
password: "password123",
});
if (signInError) throw signInError;
// Enforcement is fail closed for existing clients: archived rows are hidden
// even when their legacy is_public flag remains true.
const { data: archivedRows, error: visibilityError } = await userClient
.from("question_packs")
.select("id, content_status")
.eq("id", packId);
if (visibilityError) throw visibilityError;
assertEquals(archivedRows.length, 0);
const { error: adminError } = await adminClient.from("admin_users").insert({
user_id: userId,
role: "pack_creator",
});
if (adminError) throw adminError;
const { error: forbiddenDecision } = await userClient
.from("question_packs")
.update({
content_status: "allowed",
content_review_reason: "Pack creator should not approve content",
})
.eq("id", packId);
if (!forbiddenDecision) {
throw new Error(
"Expected pack creator catalogue decision to be rejected",
);
}
assertMatch(forbiddenDecision.message, /Only super admins/i);
const { error: promoteError } = await adminClient
.from("admin_users")
.update({ role: "super_admin" })
.eq("user_id", userId);
if (promoteError) throw promoteError;
const { data: allowed, error: allowedError } = await userClient
.from("question_packs")
.update({
content_status: "allowed",
content_review_reason: "Test super admin approval",
})
.eq("id", packId)
.select("content_status, content_reviewed_by")
.single();
if (allowedError) throw allowedError;
assertEquals(allowed.content_status, "allowed");
assertEquals(allowed.content_reviewed_by, userId);
const { data: rereviewed, error: rereviewError } = await userClient
.from("question_packs")
.update({
content_review_reason: "Test approval independently rechecked",
})
.eq("id", packId)
.select("content_status, content_review_reason, content_reviewed_by")
.single();
if (rereviewError) throw rereviewError;
assertEquals(rereviewed.content_status, "allowed");
assertEquals(
rereviewed.content_review_reason,
"Test approval independently rechecked",
);
assertEquals(rereviewed.content_reviewed_by, userId);
const { error: demoteError } = await adminClient
.from("admin_users")
.update({ role: "pack_creator" })
.eq("user_id", userId);
if (demoteError) throw demoteError;
const { error: forgedReason } = await userClient
.from("question_packs")
.update({ content_review_reason: "Pack creator forged review reason" })
.eq("id", packId);
if (!forgedReason) {
throw new Error(
"Expected pack creator review metadata change to be rejected",
);
}
assertMatch(forgedReason.message, /Only super admins/i);
const { error: forgedReviewer } = await userClient
.from("question_packs")
.update({ content_reviewed_by: null })
.eq("id", packId);
if (!forgedReviewer) {
throw new Error(
"Expected database-managed reviewer metadata to be rejected",
);
}
assertMatch(forgedReviewer.message, /database-managed/i);
const { data: guardedInsert, error: guardedInsertError } = await userClient
.from("question_packs")
.insert({
name: `Guarded content review insert ${suffix}`,
is_public: true,
is_premium: false,
is_explicit: false,
content_status: "allowed",
content_review_reason: "Forged approval on insert",
content_reviewed_at: new Date().toISOString(),
content_reviewed_by: userId,
})
.select(
"id, content_status, content_review_reason, content_reviewed_at, content_reviewed_by",
)
.single();
if (guardedInsertError) throw guardedInsertError;
insertedPackId = guardedInsert.id;
assertEquals(guardedInsert.content_status, "unreviewed");
assertEquals(guardedInsert.content_review_reason, null);
assertEquals(guardedInsert.content_reviewed_at, null);
assertEquals(guardedInsert.content_reviewed_by, null);
const { data: edited, error: editError } = await userClient
.from("question_packs")
.update({ name: `Edited content review test ${suffix}` })
.eq("id", packId)
.select("content_status, content_review_reason")
.single();
if (editError) throw editError;
assertEquals(edited.content_status, "unreviewed");
assertEquals(
edited.content_review_reason,
"Visible content changed; review required",
);
const { data: reviews, error: reviewsError } = await adminClient
.from("content_reviews")
.select("previous_status, new_status, reason, changed_by")
.eq("entity_type", "question_packs")
.eq("entity_id", packId)
.order("created_at", { ascending: true });
if (reviewsError) throw reviewsError;
assertEquals(reviews.length, 4);
assertEquals(reviews[0].previous_status, "unreviewed");
assertEquals(reviews[0].new_status, "archived");
assertEquals(reviews[1].previous_status, "archived");
assertEquals(reviews[1].new_status, "allowed");
assertEquals(reviews[1].changed_by, userId);
assertEquals(reviews[2].previous_status, "allowed");
assertEquals(reviews[2].new_status, "allowed");
assertEquals(reviews[2].changed_by, userId);
assertEquals(reviews[3].previous_status, "allowed");
assertEquals(reviews[3].new_status, "unreviewed");
assertEquals(reviews[3].changed_by, userId);
const { data: hiddenReviews, error: hiddenReviewsError } = await userClient
.from("content_reviews")
.select("id")
.eq("entity_type", "question_packs")
.eq("entity_id", packId);
if (hiddenReviewsError) throw hiddenReviewsError;
assertEquals(hiddenReviews.length, 0);
const { error: forgedAuditInsert } = await userClient
.from("content_reviews")
.insert({
entity_type: "question_packs",
entity_id: packId,
previous_status: "unreviewed",
new_status: "allowed",
reason: "Forged audit event",
});
if (!forgedAuditInsert) {
throw new Error("Expected authenticated audit insertion to be rejected");
}
} finally {
if (insertedPackId) {
await adminClient
.from("question_packs")
.delete()
.eq("id", insertedPackId);
}
if (packId) {
await adminClient
.from("content_reviews")
.delete()
.eq("entity_type", "question_packs")
.eq("entity_id", packId);
await adminClient.from("question_packs").delete().eq("id", packId);
}
if (userId) {
await adminClient.from("admin_users").delete().eq("user_id", userId);
await adminClient.auth.admin.deleteUser(userId);
}
}
});
Deno.test("deleting a reviewer preserves the decision and clears its current reviewer reference", async () => {
const suffix = crypto.randomUUID();
const email = `deleted-content-reviewer-${suffix}@example.com`;
let packId: string | undefined;
let userId: string | undefined;
try {
const user = await createTestUser(email);
userId = user.id;
const { error: adminError } = await adminClient.from("admin_users").insert({
user_id: userId,
role: "super_admin",
});
if (adminError) throw adminError;
const reviewerClient = createClient(SUPABASE_URL, anonKey, {
auth: { autoRefreshToken: false, persistSession: false },
});
const { error: signInError } = await reviewerClient.auth.signInWithPassword(
{
email,
password: "password123",
},
);
if (signInError) throw signInError;
const { data: pack, error: packError } = await adminClient
.from("question_packs")
.insert({
name: `Reviewer deletion test ${suffix}`,
is_public: true,
is_premium: false,
is_explicit: false,
})
.select("id")
.single();
if (packError) throw packError;
packId = pack.id;
const { error: approveError } = await reviewerClient
.from("question_packs")
.update({
content_status: "allowed",
content_review_reason: "Approval survives reviewer deletion",
})
.eq("id", packId);
if (approveError) throw approveError;
const { error: deleteUserError } = await adminClient.auth.admin.deleteUser(
userId,
);
if (deleteUserError) throw deleteUserError;
userId = undefined;
const { data: afterDeletion, error: afterDeletionError } = await adminClient
.from("question_packs")
.select("content_status, content_review_reason, content_reviewed_by")
.eq("id", packId)
.single();
if (afterDeletionError) throw afterDeletionError;
assertEquals(afterDeletion.content_status, "allowed");
assertEquals(
afterDeletion.content_review_reason,
"Approval survives reviewer deletion",
);
assertEquals(afterDeletion.content_reviewed_by, null);
} finally {
if (packId) {
await adminClient
.from("content_reviews")
.delete()
.eq("entity_type", "question_packs")
.eq("entity_id", packId);
await adminClient.from("question_packs").delete().eq("id", packId);
}
if (userId) {
await adminClient.from("admin_users").delete().eq("user_id", userId);
await adminClient.auth.admin.deleteUser(userId);
}
}
});
+43 -36
View File
@@ -1,48 +1,55 @@
import { assertEquals } from "std/testing/asserts.ts";
import { createTestUser, createRedemptionCode, cleanup, adminClient, SUPABASE_URL } from "./utils.ts";
import { assertEquals, assertExists } from "std/testing/asserts.ts";
import {
adminClient,
cleanup,
createRedemptionCode,
createTestUser,
SUPABASE_URL,
} from "./utils.ts";
const FUNCTION_URL = `${SUPABASE_URL}/functions/v1/redeem-code`;
Deno.test("redeem-code: successfully redeems code for user", async () => {
const email = `test_redeem_${Date.now()}@example.com`;
let userIds: string[] = [];
let codeId: string | undefined;
const email = `test_redeem_${Date.now()}@example.com`;
let userIds: string[] = [];
let codeId: string | undefined;
try {
// 1. Setup User
const user = await createTestUser(email);
userIds = [user.id];
try {
// 1. Setup User
const user = await createTestUser(email);
userIds = [user.id];
// 2. Setup Code
const codeData = await createRedemptionCode();
codeId = codeData.id;
// 2. Setup Code
const codeData = await createRedemptionCode();
codeId = codeData.id;
// 3. Call Function
const res = await fetch(FUNCTION_URL, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
email,
code: codeData.code
})
});
// 3. Call Function
const res = await fetch(FUNCTION_URL, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
email,
code: codeData.code,
}),
});
const data = await res.json();
assertEquals(res.status, 200);
assertEquals(data.success, true);
const data = await res.json();
assertEquals(res.status, 200, JSON.stringify(data));
assertEquals(data.success, true);
// 4. Verify Profile is Premium
const { data: profile } = await adminClient
.from("profiles")
.select("is_premium")
.eq("id", user.id)
.single();
assertEquals(profile.is_premium, true);
// 4. Verify Profile is Premium
const { data: profile } = await adminClient
.from("profiles")
.select("is_premium")
.eq("id", user.id)
.single();
} finally {
await cleanup(userIds);
if (codeId) await adminClient.from("redemption_codes").delete().eq("id", codeId);
assertExists(profile);
assertEquals(profile.is_premium, true);
} finally {
await cleanup(userIds);
if (codeId) {
await adminClient.from("redemption_codes").delete().eq("id", codeId);
}
}
});
@@ -10,6 +10,7 @@ Deno.test("submit-response: creates match when both users answer yes", async ()
let coupleId: string | undefined;
let questionId: string | undefined;
let packId: string | undefined;
let archivedQuestionId: string | undefined;
let users: string[] = [];
try {
@@ -25,6 +26,7 @@ Deno.test("submit-response: creates match when both users answer yes", async ()
const qData = await createTestQuestion();
questionId = qData.question!.id;
packId = qData.packId;
archivedQuestionId = qData.archivedQuestion!.id;
// 3. Sign In Users to get tokens
const session1 = await signInUser(email1);
@@ -33,6 +35,21 @@ Deno.test("submit-response: creates match when both users answer yes", async ()
assert(session1?.access_token, "User 1 should have access token");
assert(session2?.access_token, "User 2 should have access token");
// A stale installed client may still hold an archived question ID. The
// service-role Edge Function must reject it instead of bypassing RLS.
const archivedResponse = await fetch(FUNCTION_URL, {
method: "POST",
headers: {
"Authorization": `Bearer ${session1!.access_token}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
question_id: archivedQuestionId,
answer: "yes",
}),
});
assertEquals(archivedResponse.status, 404);
// 4. User 1 submits 'yes'
const res1 = await fetch(FUNCTION_URL, {
method: "POST",
+46 -2
View File
@@ -4,10 +4,14 @@ import { assert } from "std/testing/asserts.ts";
export const SUPABASE_URL = Deno.env.get("SUPABASE_URL") || "http://127.0.0.1:54321";
export const SUPABASE_SERVICE_ROLE_KEY = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY");
const SUPABASE_ANON_KEY = Deno.env.get("SUPABASE_ANON_KEY");
if (!SUPABASE_SERVICE_ROLE_KEY) {
throw new Error("SUPABASE_SERVICE_ROLE_KEY is required");
}
if (!SUPABASE_ANON_KEY) {
throw new Error("SUPABASE_ANON_KEY is required");
}
export const adminClient = createClient(SUPABASE_URL, SUPABASE_SERVICE_ROLE_KEY, {
auth: {
@@ -30,7 +34,10 @@ export async function createTestUser(email: string) {
}
export async function signInUser(email: string) {
const { data, error } = await adminClient.auth.signInWithPassword({
const userClient = createClient(SUPABASE_URL, SUPABASE_ANON_KEY!, {
auth: { autoRefreshToken: false, persistSession: false },
});
const { data, error } = await userClient.auth.signInWithPassword({
email,
password: "password123",
});
@@ -41,6 +48,7 @@ export async function signInUser(email: string) {
export async function createTestQuestion(packId?: string) {
let finalPackId = packId;
let createdPack = false;
if (!finalPackId) {
const { data: pack } = await adminClient.from("question_packs").insert({
name: "Test Pack " + Math.random(),
@@ -48,6 +56,7 @@ export async function createTestQuestion(packId?: string) {
sort_order: 999
}).select().single();
finalPackId = pack!.id;
createdPack = true;
}
const { data: question } = await adminClient.from("questions").insert({
@@ -56,7 +65,42 @@ export async function createTestQuestion(packId?: string) {
intensity: 1
}).select().single();
return { question, packId: finalPackId };
const { data: archivedQuestion } = await adminClient.from("questions").insert({
pack_id: finalPackId,
text: "Archived Test Question?",
intensity: 1
}).select().single();
if (createdPack) {
const { error: packReviewError } = await adminClient
.from("question_packs")
.update({
content_status: "allowed",
content_review_reason: "Approved local integration-test fixture",
})
.eq("id", finalPackId);
if (packReviewError) throw packReviewError;
}
const { error: questionReviewError } = await adminClient
.from("questions")
.update({
content_status: "allowed",
content_review_reason: "Approved local integration-test fixture",
})
.eq("id", question!.id);
if (questionReviewError) throw questionReviewError;
const { error: archivedQuestionReviewError } = await adminClient
.from("questions")
.update({
content_status: "archived",
content_review_reason: "Archived local integration-test fixture",
})
.eq("id", archivedQuestion!.id);
if (archivedQuestionReviewError) throw archivedQuestionReviewError;
return { question, archivedQuestion, packId: finalPackId };
}
export async function createRedemptionCode() {
+1 -1
View File
@@ -101,7 +101,7 @@ export default function RedeemPage() {
</p>
{/* Form */}
<form onSubmit={handleSubmit} className="glass-light p-6 sm:p-8">
<form noValidate onSubmit={handleSubmit} className="glass-light p-6 sm:p-8">
{/* Error Message */}
{error && (
<div className="flex items-center gap-3 bg-red-500/10 border border-red-500/20 rounded-lg p-4 mb-6">