mirror of
https://github.com/jjbrunton/Sauci.git
synced 2026-10-31 10:19:43 +00:00
feat(content): add catalogue review metadata
Add reversible review states, audit history, and super-admin controls for catalogue entities without changing current client visibility. Stage 30 high-risk production packs for archival review, cap generated content at non-explicit language, and preserve a checksummed pre-change production catalogue snapshot.
This commit is contained in:
1 parent
b43804d8fd
commit
94c76535a4
30 files changed
+46905
-127
No files matched your search
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -5,6 +5,8 @@ const AUDITED_TABLES = [
|
||||
'question_packs',
|
||||
'questions',
|
||||
'categories',
|
||||
'dare_packs',
|
||||
'dares',
|
||||
'profiles',
|
||||
'couples',
|
||||
'responses',
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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>`;
|
||||
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 },
|
||||
],
|
||||
|
||||
@@ -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',
|
||||
};
|
||||
@@ -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">
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
@@ -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,381 @@
|
||||
import { createClient } from "@supabase/supabase-js";
|
||||
import { assertEquals, assertMatch } from "std/assert/mod.ts";
|
||||
import {
|
||||
adminClient,
|
||||
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 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;
|
||||
|
||||
// The metadata-only migration must not hide content from existing clients.
|
||||
const { data: stillVisible, error: visibilityError } = await userClient
|
||||
.from("question_packs")
|
||||
.select("id, content_status")
|
||||
.eq("id", packId)
|
||||
.single();
|
||||
if (visibilityError) throw visibilityError;
|
||||
assertEquals(stillVisible.id, packId);
|
||||
assertEquals(stillVisible.content_status, "archived");
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -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`
|
||||
|
||||
@@ -0,0 +1,691 @@
|
||||
# Universal catalogue safety and store compliance implementation plan
|
||||
|
||||
Status: product direction confirmed for one universal iOS/Android catalogue.
|
||||
The additive review-metadata preparation is implemented locally; no production
|
||||
schema, content visibility, or customer application behaviour has changed.
|
||||
|
||||
## 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.
|
||||
@@ -37,6 +37,7 @@ erDiagram
|
||||
text icon
|
||||
boolean is_premium
|
||||
boolean is_public
|
||||
content_review_status content_status "metadata only; not yet enforced"
|
||||
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 "metadata only; not yet enforced"
|
||||
timestamptz created_at
|
||||
}
|
||||
|
||||
@@ -163,6 +165,23 @@ 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. The current migration is preparatory:
|
||||
existing RLS, RPCs, and customer applications do not filter `content_status`
|
||||
until the separately verified universal enforcement phase.
|
||||
|
||||
## Key Flows
|
||||
|
||||
|
||||
@@ -18,6 +18,17 @@ export interface Profile {
|
||||
onboarding_version?: number;
|
||||
}
|
||||
|
||||
/** 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;
|
||||
@@ -26,7 +37,7 @@ export interface Couple {
|
||||
}
|
||||
|
||||
// Question pack
|
||||
export interface QuestionPack {
|
||||
export interface QuestionPack extends ContentReviewMetadata {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
@@ -38,7 +49,7 @@ export interface QuestionPack {
|
||||
}
|
||||
|
||||
// Individual question
|
||||
export interface Question {
|
||||
export interface Question extends ContentReviewMetadata {
|
||||
id: string;
|
||||
pack_id: string;
|
||||
text: string;
|
||||
|
||||
Reference in new issue
Block a user