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:
jjbrunton committed 2026-08-28 11:32:55 +01:00
1 parent b43804d8fd
commit 94c76535a4
30 files changed
+46906 -128

No files matched your search

@@ -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">