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
+48476 -599

No files matched your search

+31 -1
View File
@@ -8,6 +8,11 @@ on:
required: false
type: boolean
default: false
deploy_infrastructure:
description: Apply Terraform as part of this manual deployment
required: false
type: boolean
default: false
push:
branches:
- master
@@ -17,6 +22,30 @@ on:
- '.github/workflows/deploy-supabase.yml'
jobs:
changes:
name: Detect infrastructure changes
runs-on: ubuntu-latest
outputs:
infrastructure: ${{ steps.infrastructure.outputs.changed }}
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- id: infrastructure
name: Check Terraform scope
env:
BEFORE_SHA: ${{ github.event.before }}
CURRENT_SHA: ${{ github.sha }}
DEPLOY_INFRASTRUCTURE: ${{ inputs.deploy_infrastructure }}
run: |
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
echo "changed=${DEPLOY_INFRASTRUCTURE:-false}" >> "$GITHUB_OUTPUT"
elif git diff --quiet "$BEFORE_SHA" "$CURRENT_SHA" -- terraform/; then
echo "changed=false" >> "$GITHUB_OUTPUT"
else
echo "changed=true" >> "$GITHUB_OUTPUT"
fi
quality:
name: Verify deployable source
runs-on: ubuntu-latest
@@ -128,7 +157,8 @@ jobs:
deploy-infrastructure:
name: Deploy Infrastructure (Terraform)
runs-on: ubuntu-latest
needs: quality
needs: [quality, changes]
if: needs.changes.outputs.infrastructure == 'true'
env:
TF_VAR_supabase_access_token: ${{ secrets.SUPABASE_ACCESS_TOKEN }}
+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
+14 -27
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"`;
- 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 */}
+10 -131
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,13 +129,8 @@ 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');
Events.onboardingStageComplete('purpose');
};
const handleEnableNotifications = async () => {
@@ -182,18 +169,17 @@ 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 updateData = {
name: name.trim(),
gender,
usage_reason: usageReason,
max_intensity: maxIntensity,
show_explicit_content: showExplicitContent,
hide_nsfw: hideNsfw,
// 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,7 +585,6 @@ 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>
<View style={styles.backButtonPlaceholder} />
@@ -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,26 +18,10 @@ 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}
@@ -49,16 +30,25 @@ export const PrivacySettings: React.FC<PrivacySettingsProps> = ({
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,9 +26,6 @@ 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
+3
View File
@@ -48,3 +48,6 @@ 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");
}
}
+15 -4
View File
@@ -185,16 +185,26 @@ 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) {
} 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 (eligibleSafePacks.length) {
const { error: insertError } = await supabase
.from("couple_packs")
.insert(safePacks.map((pack: { id: string }) => ({
.insert(eligibleSafePacks.map((pack: { id: string }) => ({
couple_id: couple.id,
pack_id: pack.id,
enabled: true,
@@ -204,6 +214,7 @@ Deno.serve(async (req) => {
console.error("Failed to enable safe packs:", insertError);
}
}
}
return new Response(
JSON.stringify({
@@ -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" } }
@@ -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$
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$
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$
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$
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$
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$
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$
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$
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);
}
}
});
+15 -8
View File
@@ -1,6 +1,11 @@
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`;
@@ -24,12 +29,12 @@ Deno.test("redeem-code: successfully redeems code for user", async () => {
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
email,
code: codeData.code
})
code: codeData.code,
}),
});
const data = await res.json();
assertEquals(res.status, 200);
assertEquals(res.status, 200, JSON.stringify(data));
assertEquals(data.success, true);
// 4. Verify Profile is Premium
@@ -39,10 +44,12 @@ Deno.test("redeem-code: successfully redeems code for user", async () => {
.eq("id", user.id)
.single();
assertExists(profile);
assertEquals(profile.is_premium, true);
} finally {
await cleanup(userIds);
if (codeId) await adminClient.from("redemption_codes").delete().eq("id", codeId);
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">
+1
View File
@@ -26,6 +26,7 @@ These contain useful history and acceptance details, but may describe planned or
partially implemented states. Confirm claims against current code.
- `admin-question-types-plan.md`
- [Universal catalogue safety plan](product/catalog-content-safety-plan.md)
- `content-balance-implementation.md`
- `dares-feature.md`
- `live-draw-feature-plan.md`
+5 -1
View File
@@ -19,4 +19,8 @@ All schema changes are versioned local files under `apps/supabase/migrations/`.
- Never insert migration history manually except an explicitly approved repair.
- Never hardcode a production URL in a migration or cron definition.
CI deploys committed migrations. Local verification must not link a remote project.
CI deploys committed migrations and Edge Functions to non-production after relevant
pushes. Production promotion is a manual workflow dispatch. Terraform is applied
only when files under `terraform/` change, or when `deploy_infrastructure` is
explicitly selected during a manual deployment. Local verification must not link a
remote project.
+693
View File
@@ -0,0 +1,693 @@
# Universal catalogue safety and store compliance implementation plan
Status: product direction confirmed for one universal iOS/Android catalogue.
The additive review metadata and conservative archive classification are live.
The legacy-client cutover uses RLS, guarded RPCs, write rejection, match hiding,
and pack-setting enforcement so installed iOS and Android clients receive the
same explicitly allowed catalogue without a platform-specific fork.
## 1. Outcome and non-negotiable invariants
Sauci keeps its core compatibility mechanic: each partner privately answers the
same preference or boundary prompt and Sauci reveals agreement. The production
catalogue on both iOS and Android must not supply explicit sexual acts, sexual
instructions, fetish content, or other developer-authored material intended for
sexual gratification.
The eligible catalogue is global, not platform-specific. Both members of a
couple must always be able to retrieve the same source question regardless of
which supported device they use. Platform, app version, premium state, intensity
preferences, or which partner answered first cannot make an archived question
visible.
The implementation is complete only when all of these are true:
1. All production clients can receive only content explicitly reviewed as allowed.
2. `unreviewed` is treated exactly like `archived` everywhere.
3. A pack, its category, and the individual question must all be allowed before
the question can be returned.
4. `is_explicit`, intensity, premium access, and `hide_nsfw` remain product
controls; none of them can override the catalogue safety boundary.
5. Old and unknown clients receive the same reviewed catalogue as current clients.
6. Filtering applies to catalogue browsing, swipe selection, direct links,
pending questions, answers, matches, chat headers, progress, search, counts,
realtime payloads, notification destinations, and write endpoints.
7. Historical responses and matches are retained. They are not disclosed in any
customer response when their source content is archived.
8. New developer-authored content defaults to `unreviewed` and cannot reach the
production catalogue until a recorded review allows it.
9. Cross-platform couples receive identical global content eligibility, pending
state, match visibility, progress counts, and notification destinations.
10. Couple isolation, premium rules, role checks, and RLS remain enforced.
This is a catalogue policy, not an age gate. An 18+ confirmation or content
toggle does not make otherwise prohibited developer-authored content eligible.
## 2. Content model
### 2.1 Classification used by engineering
Each category, question pack, question, dare pack, and dare has one production
review status:
| Status | Production behaviour | Meaning |
|---|---|---|
| `unreviewed` | hidden and non-actionable | no affirmative decision exists |
| `allowed` | eligible, subject to parent visibility and access rules | manually reviewed against the Play policy |
| `archived` | hidden and non-actionable | explicit, sexual-gratification, fetish, instructional, or otherwise unsuitable |
An allowed question should describe a non-graphic preference, relationship
dynamic, comfort level, romantic interest, or boundary. It must not name or
instruct a sexual act, explicit anatomy, sex toy use, nudity as stimulation,
public sexual activity, or coercive/predatory scenario.
Examples of the intended retained mechanic:
- "Take the lead more often with your partner."
- "Let your partner surprise you with a private date idea."
- "Explore a new dynamic with your partner."
- "Share a fantasy in your own words."
The last example lets the users supply the final detail. The app must present it
as private couple communication, not solicit a named sexual act or provide an
explicit example. User-authored fields still need reporting/blocking, terms,
rate limits, and safe notification previews.
### 2.2 Initial catalogue decision
The first production review is ID-based, not keyword-based. Keyword scans can
queue content for review but must never auto-allow it.
Immediately block the exact questions shown in the rejection evidence and all
developer-authored explicit acts/instructions. Use a conservative first pass:
- Block whole packs pending line review: All Eyes On Us, Boundary Discovery,
Dare to Play, Deep in Character, Dream Scenarios, Fantasy Scenarios, Intimate
Games, Kink Discovery, Know Your Limits, Leveling Up Together, Pleasure
Experiments, Position Exploration, Private Line, Pushing Limits, Role Play
Ideas, Secret Desires, Sensory Play, Sensual Touch, Starting Out, Tease &
Tension, The Deep End, and Total Control.
- Line-review the apparently safer relationship packs before allowing them;
benign pack names do not prove every contained question is safe.
- Archive the mixed packs Missing You, Testing the Waters, Romantic Gestures,
First Impressions, Body Mapping, Love Notes, Sensual Massage, and Date Night Sparks
pending rewrite. A 2026-08-27 production audit found direct references to
sex, sexual positions, nudity, striptease, underwear, lap dances, erotic
touching, or strongly sexual scenarios in all eight.
- Line-review every apparently safer relationship pack before allowing it;
benign pack names and low intensity values do not prove every
contained question is safe.
- Treat every dare and dare pack as `unreviewed` until the dare feature is
separately reviewed. The current coming-soon copy must also be rewritten
to neutral relationship language.
Do not delete historical records in place. Classification removes archived source
content from both apps while preserving subscriptions, IDs, responses, matches,
messages, and the ability to audit or later rewrite content.
### 2.3 Implemented preparation checkpoint
Migration `20260827215907_add_catalog_content_review_metadata.sql` adds the
reversible `unreviewed`/`allowed`/`archived` metadata, append-only decision
history, edit-invalidates-review triggers, and the ID-based 30-pack conservative
archive set. It deliberately does **not** filter RLS, RPCs, API queries, or either mobile
app. Existing clients therefore continue to see the same catalogue until the
later universal enforcement phase is explicitly deployed and verified.
The admin content screens expose the metadata and permit review decisions only
to super-admins. Existing explicit/crude generator settings remain accepted as
legacy inputs for compatibility, but no longer relax the universal prompt safety
ceiling. Newly created database content still defaults to `unreviewed`.
This checkpoint is reversible metadata preparation, not Play remediation. The
remaining phases below are required before archived content is hidden from any
customer.
## 3. Database changes
Create one canonical Supabase migration with:
```sh
npm run db:migration:new -- add_catalog_content_policy
```
The migration lives only in `apps/supabase/migrations/`. The untracked Drizzle
work under `apps/api/drizzle/` must not become an independent schema authority;
update `apps/api/src/db/schema.ts` to mirror the canonical schema after the
Supabase migration exists.
There is a repository-level sequencing conflict to resolve in the implementation
PR: the root `AGENTS.md` makes Supabase migrations canonical, while the current
uncommitted `docs/architecture/backend-migration.md` describes API-owned
standalone PostgreSQL migrations. This plan follows the root instruction. Update
the architecture decision before changing that ownership rule; do not quietly
create the compliance schema twice with divergent migrations.
The compliance fields must exist in whichever database is authoritative at the
time of release. If the standalone-backend migration has not cut over, enforce
them in production Supabase first and carry the values through the controlled
data migration. If the standalone backend cuts over first, import and verify the
same statuses there and leave legacy Supabase fail-closed/read-only. Never let
old clients write Supabase while new clients write a standalone database; that
would split responses and matches between two sources of truth.
### 3.1 Types and columns
Add:
```sql
create type content_review_status as enum
('unreviewed', 'allowed', 'archived');
alter table categories add column content_status
content_review_status not null default 'unreviewed';
alter table question_packs add column content_status
content_review_status not null default 'unreviewed';
alter table questions add column content_status
content_review_status not null default 'unreviewed';
alter table dare_packs add column content_status
content_review_status not null default 'unreviewed';
alter table dares add column content_status
content_review_status not null default 'unreviewed';
```
Add the same review metadata to all five tables:
- `content_review_reason text`
- `content_reviewed_at timestamptz`
- `content_reviewed_by uuid references auth.users(id) on delete set null`
Add `catalog_policy_revision integer not null default 1` and
`catalog_emergency_disabled boolean not null default false` to the singleton
`app_config`. Increment the revision whenever classifications or eligibility
rules change. The emergency switch disables catalogue reads and writes for all
clients; it does not expose an alternate catalogue.
Add indexes used by all disclosure paths:
```sql
create index categories_content_catalog_idx
on categories (content_status, is_public, sort_order);
create index question_packs_content_catalog_idx
on question_packs (content_status, is_public, category_id, sort_order);
create index questions_content_catalog_idx
on questions (pack_id, content_status, intensity)
where deleted_at is null;
create index dare_packs_content_catalog_idx
on dare_packs (content_status, is_public, category_id, sort_order);
create index dares_content_catalog_idx
on dares (pack_id, content_status, intensity);
```
### 3.2 Durable review audit
Add an append-only `content_reviews` table:
- `id uuid primary key default gen_random_uuid()`
- `entity_type text` checked to the five supported table types
- `entity_id uuid not null`
- `previous_status content_review_status not null`
- `new_status content_review_status not null`
- `reason text not null`
- `reviewed_by uuid references auth.users(id) on delete set null`
- `created_at timestamptz not null default now()`
Only super admins can read it. Clients cannot insert, update, or delete it.
Use database triggers on the five content tables to:
- reject a status change without a non-blank reason;
- set `reviewed_at` and `reviewed_by` from database time and `auth.uid()`;
- append the old/new decision to `content_reviews`;
- increment `app_config.catalog_policy_revision` after a real status change.
This replaces reliance on the admin's current best-effort, client-side audit
call for compliance decisions. Existing `audit_logs` remain useful for normal
CRUD history.
### 3.3 Eligibility helpers
Add stable SQL helpers used consistently by RPCs and tests:
- `is_category_content_allowed(category_id uuid)`
- `is_pack_content_allowed(pack_id uuid)`
- `is_question_content_allowed(question_id uuid)`
- dare equivalents
A question is eligible only when it is active, its own status is `allowed`, its
pack is public and `allowed`, and its optional category is public and `allowed`.
Premium and couple-pack rules are applied in addition to this predicate. When
`catalog_emergency_disabled` is true, every customer eligibility helper returns
false; privileged admin review remains available.
Recommendation order can still vary by a user's intensity preference, gender
targeting, answer history, and which inverse wording applies. Once either partner
answers an allowed question, however, the other partner must be able to retrieve
the corresponding pending question even when it would not have appeared in
their ordinary recommendation queue. No user preference may orphan that pending
interaction.
### 3.4 Legacy-client fail-closed boundary
Old builds query the public Supabase tables and RPCs directly. Change
public/authenticated SELECT policies so non-admin direct access exposes only
globally allowed rows. Preserve the existing admin policy as a separate
permissive policy.
Specifically drop every overlapping non-admin visibility policy (including the
current public-pack and premium-pack policies) and replace the broad policies
for `categories`, `question_packs`, `questions`, `dare_packs`, and `dares`. The
question policy must include
`deleted_at is null`, question status, parent pack status/public access, category
status/public access, premium access, and existing gender rules where relevant.
Recreate every SECURITY DEFINER content RPC with the same fail-closed predicate,
including at minimum:
- `get_recommended_questions`
- `get_answer_gap_status`
- `get_daily_response_limit_status` if its counts are presented as catalogue progress
- pack statistics/progress functions
- dare catalogue and statistics functions
`get_recommended_questions(target_pack_id)` must reject an archived/unreviewed
target pack rather than trusting the supplied UUID. It must also filter
the individual question and exclude soft-deleted questions.
This makes legacy iOS and Android builds consistent immediately. Neither app has
a broader fallback catalogue, so a partner cannot create a pending question or
match that the other partner's platform is forbidden to retrieve.
### 3.5 Write enforcement
Reads alone are insufficient. A user who retained an old question UUID must not
be able to submit or change a response to it from any client.
Move submission/update into the API and enforce content eligibility before
writing. During the legacy window, update `submit-response` and
`update-response` edge functions to apply the same universal check. Archived IDs
return `404 content_not_available` without text or policy details.
The same rule applies to:
- enabling a pack in `couple_packs`;
- creating a sent dare from a developer-authored dare;
- retrieving or mutating a historical response;
- opening a match/chat tied to archived developer content.
Keep existing rows. Do not cascade-delete responses, matches, messages, or media.
### 3.6 Device records for notification safety
Replace the single `profiles.push_token` as the long-term delivery model with
`user_devices`:
- `id uuid primary key`
- `user_id uuid not null references profiles(id) on delete cascade`
- `expo_push_token text not null unique`
- `platform text` checked to `ios` or `android`
- `app_version text`, `build_number integer`
- `catalog_policy_revision integer not null`
- `last_seen_at`, `created_at`, `updated_at`
- `disabled_at timestamptz`
RLS permits a user to manage only their own device rows. Backend workers read
them privately. Every device gets the same eligible counts and destinations.
Keep `profiles.push_token` during one release as a compatibility fallback, then stop
writing it and remove it in a later migration after delivery metrics confirm the
cutover.
## 4. API changes
The new API is the only long-term product-data boundary. The current pack-domain
work is incomplete and not registered by `apps/api/src/app.ts`; extend that work
rather than creating another parallel path.
### 4.1 Request context and catalogue revision
Update `apps/mobile/src/lib/apiClient.ts` to send:
- `X-Sauci-Platform: ios|android` for diagnostics and release support only
- `X-Sauci-App-Version`
- `X-Sauci-Build-Number`
- `X-Sauci-Catalog-Policy-Revision`
Use Expo Constants and `Platform.OS`, not environment-specific user choices.
Add typed API middleware/request context in `apps/api/src/app.ts` that parses the
version headers once. Platform metadata must never alter content eligibility.
Include the current policy revision in every catalogue response.
### 4.2 Required API surface
Implement and register authenticated routes for every product-content path:
| Route | Required behaviour |
|---|---|
| `GET /v1/catalog` | eligible categories and packs; question counts count only eligible questions |
| `GET /v1/packs/:id` | eligible metadata and preview questions; archived IDs return 404 |
| `GET /v1/me/enabled-packs` | omit archived pack IDs without deleting couple settings |
| `PUT /v1/me/enabled-packs/:id` | reject an ineligible pack |
| `GET /v1/me/pack-progress` | denominator and answered count use only eligible questions |
| `GET /v1/questions/recommended` | preserve partner/gender/intensity/premium logic plus policy filter |
| `GET /v1/questions/pending` | filter both partner response and joined question |
| `POST /v1/responses` | validate eligibility in the same transaction as the write/match calculation |
| `PATCH /v1/responses/:id` | validate ownership and current eligibility |
| `GET /v1/me/responses` | omit archived history and its question/pack text |
| `GET /v1/matches` | omit archived matches and exclude them from badges/counts |
| `GET /v1/matches/:id` | archived IDs return 404 before returning question, summary, messages, or media URLs |
| dare routes | same parent/item/read/write enforcement; do not expose until reviewed |
| `PUT/DELETE /v1/me/devices/:token` | register/disable device metadata |
All customer-facing repository queries use one centralized eligibility
predicate. Do not accept a boolean such as `showAdult`, a platform argument, or
a hidden full-catalogue option. Separate privileged admin repositories may read
all statuses after role checks. Customer repository tests must fail if the
eligibility predicate is omitted.
Do not return hidden text in error bodies, logs, analytics, or tracing tags.
### 4.3 Transaction and isolation rules
- Derive `user_id` from the verified JWT, never request JSON.
- Derive `couple_id` from the authenticated profile.
- On submission, lock/read the eligible question and existing responses inside
one transaction, then upsert the response and match.
- Every match/message query checks the authenticated user's current couple.
- Pack toggles use the authenticated couple and an eligible pack in one query.
- Preserve current premium, answer-gap, daily-limit, gender-targeting, inverse,
and question-type behaviour while adding the content predicate.
## 5. Mobile changes
### 5.1 Remove content reads from `legacyDataClient`
Migrate these exact surfaces to typed API methods:
- `src/store/packsStore.ts`: catalogue, enabled packs, toggles, progress.
- `src/features/swipe/services/swipeService.ts`: pack context, recommended and
pending questions, status checks, and response submission.
- `app/pack/[id].tsx`: pack/question preview and direct-link handling.
- `src/store/matchStore.ts`: all match, pending, their-turn, response, and nudge
context queries.
- `src/store/responsesStore.ts`: My Answers, pagination, counts, edits.
- `src/features/chat/ChatScreen.tsx`: match/question header and initial access.
- `app/(app)/_layout.tsx`: realtime match hydration and toast copy.
- future dare stores/screens and all content search surfaces.
Auth may continue to use `authClient`. Realtime can temporarily use Supabase only
as an invalidation signal containing IDs; the app must refetch the visible DTO
from the API before rendering it. Never render embedded realtime question text.
### 5.2 Typed DTOs and safe UI states
Add shared types and Zod schemas in `packages/shared`, exported through
`src/index.ts`, for request context, catalogue, questions, responses, matches,
device registration, `content_not_available`, and policy revision.
Archived/stale deep links should navigate to a neutral screen: "This item is no
longer available in Sauci." Do not display the old title or
question. The same neutral state is used when an item becomes archived while the
app is open.
On policy revision change or app resume:
- cancel in-flight catalogue requests;
- clear packs, swipe queue, pending, matches, response-history, and chat-header
stores;
- clear React Query content keys;
- discard any current card before refetching;
- retain only IDs needed for locally skipped questions; no question text is
persisted.
Add a response interceptor for `404 content_not_available` and policy revision
mismatch so every screen gets the same clearing behaviour.
### 5.3 Copy and settings
- Remove the promise of explicit content from both apps' onboarding,
settings, subscription paywalls, empty states, analytics screen names, and the
dares coming-soon page.
- Remove `hide_nsfw` and any setting that implies archived adult content can be
unlocked. Retain intensity only as ordering/pacing inside the allowed catalogue
and review whether its user-facing labels also need neutral wording.
- Describe Sauci's value as private compatibility, curiosity, boundaries,
relationship dynamics, and user-led conversation.
- Never seed an explicit example or placeholder into a free-text field.
### 5.4 Private user-authored content
If Sauci adds "write your own" prompts to preserve the final jump:
- it is a separate question type and database record, visibly labelled as
written by the partner rather than Sauci;
- content is private to the couple and not discoverable, searchable, or reused;
- notifications say only that a partner shared a private prompt;
- provide report, delete, block-partner/leave-couple, and safety help actions;
- rate-limit creation and validate length/media type;
- do not generate, complete, recommend, categorize, or provide explicit starter
text;
- include UGC moderation and enforcement terms before enabling it in either app.
This feature should be a later submission, not mixed into the remediation build.
## 6. Admin changes
Update Categories, Packs, Questions, Dare Packs, and Dares pages/services to add:
- catalogue status badge and reason;
- filters for unreviewed/allowed/archived and "parent archives child";
- single and bulk review actions available only to `super_admin`;
- required reason and confirmation showing affected question counts;
- a queue sorted with rejection-evidence items first;
- a read-only review history panel from `content_reviews`;
- dashboard counts and a hard warning when any public item is unreviewed;
- CSV export/import by immutable ID for offline line review, with dry-run diff;
- publish/scheduled-release validation that does not imply Play eligibility.
Review inverse question pairs together. A transaction-backed admin API/RPC must
change both sides atomically and reject mismatched pair status. Do not add a hard
constraint until the existing inverse data has been audited and repaired, since
legacy pairs may not currently be reciprocal.
Creating or materially editing text resets that item to `unreviewed`, clears its
review metadata, and increments the policy revision. Editing pack/category text
also resets that parent. Reordering, icon/color changes, and pricing changes do
not reset status unless visible copy changes.
Update `AUDITED_TABLES` to include dare tables and the new review table, while
keeping the database review-event trigger as the authoritative compliance log.
## 7. Notifications, jobs, and cached disclosure
Update all notification workers and scheduled functions:
- `send-notification`
- `send-message-notification`
- `send-match-notification-digest`
- `send-partner-activity-notification`
- `send-catchup-reminder`
- `send-weekly-summary`
- future dare notification functions
Counts for every device include only eligible source questions.
Never include question, pack, dare, response, or message text in a push preview.
A tap may carry an opaque match ID, but the target route must re-authorize and
re-evaluate eligibility. Suppress a push when its only destination would be a
archived item.
Realtime payloads are invalidations, not renderable content. Clear current
content on sign-out, relationship deletion, and policy revision. Verify
SecureStore/AsyncStorage migrations do not contain prompt text.
## 8. Migration and release sequence
### Phase A - content freeze and evidence
1. Freeze developer-authored pack/question/dare publishing.
2. Export immutable IDs, visible copy, pack/category, intensity, inverse ID,
public/premium flags, and response/match counts from production.
3. Save the exact rejection screenshots and map every shown card to its question
ID. Do not store subscriber answers or message content in the review file.
4. Produce an allow/block CSV reviewed by a human; unresolved rows remain
`unreviewed`.
5. Capture before-state counts by status, pack, responses, and matches.
### Phase B - schema and API locally
1. Create the canonical migration and seed the reviewed ID decisions in the same
migration or a separate explicit data migration. Never classify by mutable
pack name in production SQL.
2. Update generated Supabase types and the Drizzle schema mirror.
3. Implement API request context, repositories, routes, device registration,
and policy-aware notification queries.
4. Update edge functions and legacy RPCs for fail-closed compatibility.
5. Reset local Supabase and run SQL/RLS/function tests.
### Phase C - mobile and admin
1. Move all listed mobile content paths to the API.
2. Add revision-driven cache clearing and neutral unavailable states.
3. Build admin review queue, atomic bulk review, audit view, and release guards.
4. Rewrite sexualized marketing/UI copy consistently in both apps.
5. Complete content review; no public production item remains `unreviewed`.
### Phase D - controlled production cutover
1. Declare and record the one authoritative product database for this release.
If the standalone migration is incomplete, do not mix its writes with live
Supabase writes; perform the compliance cutover on the current source first.
2. Deploy the additive schema and API. Keep
`catalog_emergency_disabled=false` unless the catalogue must be stopped
completely during cutover.
3. Read back schema, seeded decisions, RLS definitions, status counts, and a
sample of each endpoint. Do not expose text in deployment logs.
4. Enable the legacy RLS/RPC boundary so every old iOS and Android build receives
only allowed content, then independently test production with both legacy
clients.
5. Ship API-based iOS and Android builds from the same catalogue-policy revision.
Verify a cross-platform couple in both answer directions.
6. Deploy Android version code greater than 45 to internal testing. Verify a new
user and an existing production-like user with historical adult responses.
7. Keep the universal allowlist unchanged during the first Play review.
### Phase E - Play submission
1. Deactivate superseded non-compliant bundles from every active testing track
that is included in review.
2. Complete the content-rating questionnaire from the remediated binary and
catalogue, not the previous app. Do not under-declare remaining content.
3. Provide working reviewer credentials and deterministic navigation notes.
4. In the submission note, state that the cited Fantasy Scenarios, Sensual
Touch, and Pushing Limits content has been removed from Sauci's production
catalogue on both platforms and that new content is manually allowlisted.
5. Attach concise evidence from the exhaustive Android crawl. Do not argue that
competitor apps prove compliance.
6. Submit one clean remediation build; do not repeatedly test policy boundaries
with incremental explicit variants.
## 9. Verification matrix
### Database
- Fresh local reset applies every migration.
- New content defaults to `unreviewed`.
- Non-admin direct SELECT cannot see unreviewed/archived child or parent rows.
- Admin can see all rows without weakening couple/user RLS.
- Direct target-pack RPC calls cannot retrieve an archived pack.
- Soft-deleted questions never appear.
- Status changes require a reason, record actor/time, append one audit event,
and increment policy revision.
- Content-text edits reset status; metadata-only edits behave as specified.
- Existing response/match/message row counts are unchanged by classification.
### API
- iOS, Android, missing headers, invalid headers, old builds, and unknown future
builds all get exactly the same allowlist.
- Every catalogue count equals its filtered children.
- A known archived UUID returns 404 on pack, question, response, match, chat,
toggle, and dare endpoints.
- Pending, answer gap, daily limit, badges, and progress exclude archived items.
- Couple A cannot infer IDs, counts, responses, matches, or devices for Couple B.
- Premium cannot bypass content status.
- Logs/errors never contain text from archived content.
### Mobile clients
- Android fresh install/upgrade from version 45 and iOS fresh install/upgrade;
existing subscriber, non-subscriber, paired/unpaired, and user with historical
adult matches.
- Browse every category/pack/card; test direct links and notification taps.
- Test Matches tabs, My Answers, chat header, realtime toasts, search, empty
states, settings, onboarding, subscription screens, and dare interest page.
- Change policy revision while an archived card is on screen; it disappears before
any further render/action.
- Attempt traffic replay with an archived UUID; UI shows only the neutral state.
- Automated screenshot/text crawls on both apps contain none of the rejection
evidence or disallowed developer-authored acts.
- An iOS user answers first and an Android partner can retrieve and answer the
same question; repeat with Android first, then verify matching progress,
badges, match creation, chat header, history, and notification destination.
- Archived historical records remain stored but are hidden consistently from both
partners on both platforms.
Run the repository gates in order: focused unit/integration tests,
`npm run verify:fast`, local database reset/RLS tests, API E2E, native iOS and
Android verification, then `npm run verify:full`. A missing simulator, database,
or credential is reported as an environment limitation, not a pass.
## 10. Observability and rollback
Emit metrics without content text:
- catalogue responses by policy revision/build/platform;
- count of allowed/archived/unreviewed entities;
- archived-ID read/write attempts by route;
- policy revision adoption by active device;
- notification suppressions by reason;
- legacy direct-client usage and API migration percentage.
Add a nightly invariant check that fails if any customer API response could join
a non-allowed entity, if an allowed child has an archived parent, if two members of
a couple can receive different eligibility for the same question, or if an
allowed inverse pair is inconsistent. Alert rather than auto-classifying.
Rollback is configuration-first: set `catalog_emergency_disabled=true`,
invalidate caches by incrementing the policy revision, and disable affected API routes. Do
not roll back the additive schema or restore broad direct-table RLS. Content
decisions can be reverted only through a new audited review event.
## 11. Concrete file checklist
Database and functions:
- `apps/supabase/migrations/20260827215907_add_catalog_content_review_metadata.sql`
- `apps/supabase/functions/submit-response/index.ts`
- `apps/supabase/functions/update-response/index.ts`
- all notification/catch-up/summary functions listed above
- regenerated `packages/shared/src/types/supabase.ts`
- regenerated `apps/mobile/src/types/supabase.ts` if it remains during migration
API and shared contracts:
- `apps/api/src/app.ts`
- new request-context/content-policy modules
- `apps/api/src/db/schema.ts`
- `apps/api/src/domains/packs/{repository,routes,schema}.ts`
- new questions, responses, matches, devices, notifications, and dares domains
- route/repository/integration tests for every domain
- `packages/shared/src/types/index.ts` plus domain-specific schema/type modules
Mobile:
- `apps/mobile/src/lib/apiClient.ts`
- stores/services/screens listed in section 5.1
- `apps/mobile/src/lib/notifications.ts`
- `apps/mobile/app/_layout.tsx` and `apps/mobile/app/(app)/_layout.tsx`
- new content-policy/cache reset module and tests
- iOS and Android onboarding/settings/paywall/dare copy
Admin:
- content pages and services for categories, packs, questions, dare packs, dares
- `apps/admin/src/hooks/useAuditedSupabase.ts`
- new review queue/history/bulk-confirmation components and tests
- dashboard compliance counts
Documentation:
- `docs/architecture/system.md` for the enforced API boundary
- `docs/question-selection.md` for universal eligibility-aware selection
- `docs/match-notifications.md` for device-aware safe delivery
- `docs/schema.md` for review/device tables
- `docs/releasing.md` for the cross-platform catalogue and Android compliance gates
- `docs/testing/strategy.md` for the cross-platform matrix
- `docs/index.md` linking this maintained plan while remediation is active
## 12. Definition of done
The remediation is not done when the three cited packs disappear from the pack
screen. It is done when legacy and new iOS/Android clients, direct Supabase
requests, guessed IDs, history, matches, chat, realtime, and push paths all fail
closed; the audited allowlist is complete; historical data is preserved; both
partners observe the same eligibility; and the end-to-end crawls prove no
disallowed developer-authored content can be reached.
+24 -1
View File
@@ -37,6 +37,7 @@ erDiagram
text icon
boolean is_premium
boolean is_public
content_review_status content_status "allowed required for customer access"
int sort_order
timestamptz created_at
}
@@ -50,6 +51,7 @@ erDiagram
text[] allowed_couple_genders "couple composition filter"
text[] target_user_genders "individual user filter"
text[] required_props "props/accessories required"
content_review_status content_status "allowed required for customer access"
timestamptz created_at
}
@@ -163,6 +165,26 @@ erDiagram
| `feedback_type` | `bug`, `feature_request`, `general` |
| `feedback_status` | `new`, `reviewed`, `in_progress`, `resolved`, `closed` |
| `subscription_status` | `active`, `cancelled`, `expired`, `billing_issue`, `paused` |
| `content_review_status` | `unreviewed`, `allowed`, `archived` |
| `content_entity_type` | `categories`, `question_packs`, `questions`, `dare_packs`, `dares` |
## Catalogue review metadata
`categories`, `question_packs`, `questions`, `dare_packs`, and `dares` carry
`content_status`, `content_review_reason`, `content_reviewed_at`, and
`content_reviewed_by`. Insert triggers force new rows to `unreviewed` with empty
review metadata even when a caller supplies forged values. Status changes and
same-status re-reviews require a non-blank reason, only super-admin users may
make an authenticated decision, reviewer identity/time are database-managed,
and visible content edits reset an unchanged decision to `unreviewed`.
`content_reviews` is the append-only status-change history. Only super-admins
may read it through the authenticated API. Customer catalogue RLS and the legacy
recommendation/teaser RPCs fail closed: a category, pack, and question or dare
must all be `allowed`. `unreviewed` and `archived` content remains available to
administrators but is not returned to customer clients. Service-role response
functions independently reject questions unless both question and pack are
allowed.
## Key Flows
@@ -181,7 +203,8 @@ erDiagram
### Pack Selection Flow
1. Couples can enable/disable packs via `couple_packs` junction table
2. `get_recommended_questions()` function filters questions by enabled packs
2. `get_recommended_questions()` filters by enabled packs and the universal
allowed-content boundary
3. Two-part questions show `partner_text` to second responder
### Question Targeting System
+13 -2
View File
@@ -40,6 +40,17 @@ export interface ApiErrorResponse {
};
}
/** Editorial catalogue state. Customer applications do not enforce it yet. */
export type ContentReviewStatus = 'unreviewed' | 'allowed' | 'archived';
export interface ContentReviewMetadata {
/** Optional until the additive migration has reached every environment. */
content_status?: ContentReviewStatus;
content_review_reason?: string | null;
content_reviewed_at?: string | null;
content_reviewed_by?: string | null;
}
// Couple pairing
export interface Couple {
id: string;
@@ -48,7 +59,7 @@ export interface Couple {
}
// Question pack
export interface QuestionPack {
export interface QuestionPack extends ContentReviewMetadata {
id: string;
name: string;
description: string | null;
@@ -60,7 +71,7 @@ export interface QuestionPack {
}
// Individual question
export interface Question {
export interface Question extends ContentReviewMetadata {
id: string;
pack_id: string;
text: string;