This commit is contained in:
jjbrunton committed 2025-12-30 15:55:57 +00:00
1 parent 5b97bc6f56
commit 5a7e62a578
104 files changed
+2986

No files matched your search

@@ -0,0 +1,239 @@
import { useState } from 'react';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
import { Loader2, Wand2, ArrowRight, Check } from 'lucide-react';
import { analyzeQuestionText, TextAnalysis } from '@/lib/openai';
import { auditedSupabase } from '@/hooks/useAuditedSupabase';
import { toast } from 'sonner';
interface Question {
id: string;
text: string;
partner_text: string | null;
}
interface FixTextDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
questions: Question[];
isExplicit?: boolean;
onUpdated: () => void;
}
export function FixTextDialog({ open, onOpenChange, questions, isExplicit = false, onUpdated }: FixTextDialogProps) {
const [analyzing, setAnalyzing] = useState(false);
const [suggestions, setSuggestions] = useState<TextAnalysis[]>([]);
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
const [step, setStep] = useState<'initial' | 'review'>('initial');
const [applying, setApplying] = useState(false);
const handleAnalyze = async () => {
setAnalyzing(true);
try {
const results = await analyzeQuestionText(questions, isExplicit);
setSuggestions(results);
// Default select all
setSelectedIds(new Set(results.map(r => r.id)));
setStep('review');
} catch (error) {
console.error(error);
toast.error('Failed to analyze questions');
} finally {
setAnalyzing(false);
}
};
const handleApply = async () => {
setApplying(true);
try {
const updates = suggestions.filter(s => selectedIds.has(s.id));
if (updates.length === 0) {
onOpenChange(false);
return;
}
// Apply updates in parallel with audit logging
await Promise.all(updates.map(update =>
auditedSupabase.update('questions', update.id, {
text: update.suggested_text,
partner_text: update.suggested_partner_text,
})
));
toast.success(`Updated ${updates.length} questions`);
onUpdated();
handleClose();
} catch (error) {
console.error(error);
toast.error('Failed to apply updates');
} finally {
setApplying(false);
}
};
const handleClose = () => {
onOpenChange(false);
// Reset state after transition
setTimeout(() => {
setStep('initial');
setSuggestions([]);
}, 300);
};
const toggleSelection = (id: string) => {
const next = new Set(selectedIds);
if (next.has(id)) next.delete(id);
else next.add(id);
setSelectedIds(next);
};
const getQuestion = (id: string) => questions.find(q => q.id === id);
return (
<Dialog open={open} onOpenChange={handleClose}>
<DialogContent className="max-w-5xl max-h-[90vh] flex flex-col">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Wand2 className="h-5 w-5 text-purple-500" />
Fix Question Text
</DialogTitle>
<DialogDescription>
Use AI to improve question phrasing to be direct, actionable proposals.
</DialogDescription>
</DialogHeader>
<div className="flex-1 overflow-y-auto py-4">
{step === 'initial' ? (
<div className="flex flex-col items-center justify-center py-12 text-center space-y-4">
<div className="bg-purple-50 p-4 rounded-full">
<Wand2 className="h-12 w-12 text-purple-500" />
</div>
<div className="space-y-2">
<h3 className="text-lg font-medium">Ready to analyze {questions.length} questions</h3>
<p className="text-muted-foreground max-w-md mx-auto">
The AI will suggest improved text to make questions more direct and actionable,
following the proposal style (e.g., "Give your partner a massage" instead of "Would you want to give a massage?").
</p>
</div>
<Button onClick={handleAnalyze} disabled={analyzing} size="lg" className="bg-purple-600 hover:bg-purple-700">
{analyzing ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Analyzing...
</>
) : (
'Start Analysis'
)}
</Button>
</div>
) : (
<div className="space-y-4">
{suggestions.length === 0 ? (
<div className="text-center py-8 text-muted-foreground">
<Check className="h-8 w-8 mx-auto mb-2 text-green-500" />
All questions are well-phrased! No improvements needed.
</div>
) : (
<div className="border rounded-md">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-12">
<input
type="checkbox"
className="h-4 w-4 rounded border-gray-300 accent-primary"
checked={selectedIds.size === suggestions.length && suggestions.length > 0}
onChange={(e) => {
if (e.target.checked) setSelectedIds(new Set(suggestions.map(s => s.id)));
else setSelectedIds(new Set());
}}
/>
</TableHead>
<TableHead>Current Text</TableHead>
<TableHead className="w-8"></TableHead>
<TableHead>Suggested Text</TableHead>
<TableHead>Reason</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{suggestions.map((suggestion) => {
const question = getQuestion(suggestion.id);
if (!question) return null;
return (
<TableRow key={suggestion.id}>
<TableCell>
<input
type="checkbox"
className="h-4 w-4 rounded border-gray-300 accent-primary"
checked={selectedIds.has(suggestion.id)}
onChange={() => toggleSelection(suggestion.id)}
/>
</TableCell>
<TableCell className="max-w-xs">
<div className="text-sm">{question.text}</div>
{question.partner_text && (
<div className="text-xs text-muted-foreground mt-1 italic">
Partner: {question.partner_text}
</div>
)}
</TableCell>
<TableCell>
<ArrowRight className="h-4 w-4 text-muted-foreground" />
</TableCell>
<TableCell className="max-w-xs">
<div className="text-sm font-medium text-purple-700">{suggestion.suggested_text}</div>
{suggestion.suggested_partner_text && (
<div className="text-xs text-purple-600 mt-1 italic">
Partner: {suggestion.suggested_partner_text}
</div>
)}
</TableCell>
<TableCell className="text-xs text-muted-foreground max-w-xs">
{suggestion.reason}
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</div>
)}
</div>
)}
</div>
<DialogFooter>
{step === 'review' && (
<>
<Button variant="outline" onClick={() => setStep('initial')}>
Back
</Button>
<Button
onClick={handleApply}
disabled={applying || suggestions.length === 0 || selectedIds.size === 0}
className="bg-purple-600 hover:bg-purple-700"
>
{applying ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Applying...
</>
) : (
`Apply ${selectedIds.size} Fixes`
)}
</Button>
</>
)}
</DialogFooter>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,490 @@
import { useState } from 'react';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Checkbox } from '@/components/ui/checkbox';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
import { Loader2, Sparkles, ArrowRight, Check, Wand2, ShieldCheck } from 'lucide-react';
import { analyzeQuestionText, analyzeQuestionTargets, TextAnalysis, TargetAnalysis } from '@/lib/openai';
import { auditedSupabase } from '@/hooks/useAuditedSupabase';
import { toast } from 'sonner';
interface Question {
id: string;
text: string;
partner_text: string | null;
intensity: number;
allowed_couple_genders: string[] | null;
target_user_genders: string[] | null;
}
interface ReviewQuestionsDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
questions: Question[];
isExplicit?: boolean;
onUpdated: () => void;
}
interface CombinedSuggestion {
questionId: string;
question: Question;
textSuggestion?: TextAnalysis;
targetSuggestion?: TargetAnalysis;
}
export function ReviewQuestionsDialog({ open, onOpenChange, questions, isExplicit = false, onUpdated }: ReviewQuestionsDialogProps) {
const [analyzing, setAnalyzing] = useState(false);
const [suggestions, setSuggestions] = useState<CombinedSuggestion[]>([]);
const [selectedTextIds, setSelectedTextIds] = useState<Set<string>>(new Set());
const [selectedTargetIds, setSelectedTargetIds] = useState<Set<string>>(new Set());
const [step, setStep] = useState<'initial' | 'review'>('initial');
const [applying, setApplying] = useState(false);
// Helper functions for target comparison
const isAllCoupleTargets = (targets: string[] | null): boolean => {
if (!targets || targets.length === 0) return true;
if (targets.length === 3) {
const sorted = [...targets].sort();
return sorted[0] === 'female+female' && sorted[1] === 'female+male' && sorted[2] === 'male+male';
}
return false;
};
const isAllInitiator = (targets: string[] | null): boolean => {
return !targets || targets.length === 0;
};
const arraysEqual = (a: string[] | null, b: string[] | null): boolean => {
if (!a && !b) return true;
if (!a || !b) return false;
if (a.length !== b.length) return false;
const sortedA = [...a].sort();
const sortedB = [...b].sort();
return sortedA.every((val, i) => val === sortedB[i]);
};
const handleAnalyze = async () => {
setAnalyzing(true);
try {
// Run both analyses in parallel
const [textResults, targetResults] = await Promise.all([
analyzeQuestionText(questions, isExplicit),
analyzeQuestionTargets(questions)
]);
// Filter target results to only meaningful changes
const meaningfulTargetResults = targetResults.filter(suggestion => {
const question = questions.find(q => q.id === suggestion.id);
if (!question) return false;
const currentCoupleIsAll = isAllCoupleTargets(question.allowed_couple_genders);
const suggestedCoupleIsAll = isAllCoupleTargets(suggestion.suggested_targets);
const coupleChanged = !(currentCoupleIsAll && suggestedCoupleIsAll) &&
!arraysEqual(question.allowed_couple_genders, suggestion.suggested_targets);
const currentInitiatorIsAll = isAllInitiator(question.target_user_genders);
const suggestedInitiatorIsAll = isAllInitiator(suggestion.suggested_initiator);
const initiatorChanged = !(currentInitiatorIsAll && suggestedInitiatorIsAll) &&
!arraysEqual(question.target_user_genders, suggestion.suggested_initiator);
return coupleChanged || initiatorChanged;
});
// Combine results by question ID
const questionIds = new Set([
...textResults.map(t => t.id),
...meaningfulTargetResults.map(t => t.id)
]);
const combined: CombinedSuggestion[] = Array.from(questionIds).map(id => {
const question = questions.find(q => q.id === id)!;
return {
questionId: id,
question,
textSuggestion: textResults.find(t => t.id === id),
targetSuggestion: meaningfulTargetResults.find(t => t.id === id),
};
});
setSuggestions(combined);
// Default select all
setSelectedTextIds(new Set(textResults.map(r => r.id)));
setSelectedTargetIds(new Set(meaningfulTargetResults.map(r => r.id)));
setStep('review');
} catch (error) {
console.error(error);
toast.error('Failed to analyze questions');
} finally {
setAnalyzing(false);
}
};
const handleApply = async () => {
setApplying(true);
try {
const updates: { id: string; data: Record<string, unknown> }[] = [];
// Build update objects combining text and target changes
suggestions.forEach(suggestion => {
const textSelected = selectedTextIds.has(suggestion.questionId) && suggestion.textSuggestion;
const targetSelected = selectedTargetIds.has(suggestion.questionId) && suggestion.targetSuggestion;
if (textSelected || targetSelected) {
const updateData: Record<string, unknown> = {};
if (textSelected && suggestion.textSuggestion) {
updateData.text = suggestion.textSuggestion.suggested_text;
updateData.partner_text = suggestion.textSuggestion.suggested_partner_text;
}
if (targetSelected && suggestion.targetSuggestion) {
updateData.allowed_couple_genders = suggestion.targetSuggestion.suggested_targets &&
suggestion.targetSuggestion.suggested_targets.length > 0
? suggestion.targetSuggestion.suggested_targets
: null;
updateData.target_user_genders = suggestion.targetSuggestion.suggested_initiator &&
suggestion.targetSuggestion.suggested_initiator.length > 0
? suggestion.targetSuggestion.suggested_initiator
: null;
}
if (Object.keys(updateData).length > 0) {
updates.push({ id: suggestion.questionId, data: updateData });
}
}
});
if (updates.length === 0) {
onOpenChange(false);
return;
}
// Apply updates in parallel with audit logging
await Promise.all(updates.map(update =>
auditedSupabase.update('questions', update.id, update.data)
));
toast.success(`Updated ${updates.length} questions`);
onUpdated();
handleClose();
} catch (error) {
console.error(error);
toast.error('Failed to apply updates');
} finally {
setApplying(false);
}
};
const handleClose = () => {
onOpenChange(false);
setTimeout(() => {
setStep('initial');
setSuggestions([]);
setSelectedTextIds(new Set());
setSelectedTargetIds(new Set());
}, 300);
};
const toggleTextSelection = (id: string) => {
const next = new Set(selectedTextIds);
if (next.has(id)) next.delete(id);
else next.add(id);
setSelectedTextIds(next);
};
const toggleTargetSelection = (id: string) => {
const next = new Set(selectedTargetIds);
if (next.has(id)) next.delete(id);
else next.add(id);
setSelectedTargetIds(next);
};
const formatTargets = (targets: string[] | null) => {
if (!targets || targets.length === 0) return 'All';
return targets.map(t => t.replace('male+male', 'M+M').replace('female+male', 'M+F').replace('female+female', 'F+F')).join(', ');
};
const formatInitiator = (targets: string[] | null) => {
if (!targets || targets.length === 0) return 'Any';
return targets.map(t => t.charAt(0).toUpperCase() + t.slice(1)).join(', ');
};
// Count suggestions by type
const textSuggestionCount = suggestions.filter(s => s.textSuggestion).length;
const targetSuggestionCount = suggestions.filter(s => s.targetSuggestion).length;
const totalSelectedCount = selectedTextIds.size + selectedTargetIds.size;
// Select all/none helpers
const selectAllText = () => setSelectedTextIds(new Set(suggestions.filter(s => s.textSuggestion).map(s => s.questionId)));
const selectNoneText = () => setSelectedTextIds(new Set());
const selectAllTargets = () => setSelectedTargetIds(new Set(suggestions.filter(s => s.targetSuggestion).map(s => s.questionId)));
const selectNoneTargets = () => setSelectedTargetIds(new Set());
const selectAll = () => {
selectAllText();
selectAllTargets();
};
const selectNone = () => {
selectNoneText();
selectNoneTargets();
};
return (
<Dialog open={open} onOpenChange={handleClose}>
<DialogContent className="max-w-6xl max-h-[90vh] flex flex-col">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Sparkles className="h-5 w-5 text-amber-500" />
Review Questions
</DialogTitle>
<DialogDescription>
AI-powered analysis to improve text phrasing and fix audience targeting.
</DialogDescription>
</DialogHeader>
<div className="flex-1 overflow-y-auto py-4">
{step === 'initial' ? (
<div className="flex flex-col items-center justify-center py-12 text-center space-y-6">
<div className="bg-amber-50 p-4 rounded-full">
<Sparkles className="h-12 w-12 text-amber-500" />
</div>
<div className="space-y-2">
<h3 className="text-lg font-medium">Ready to analyze {questions.length} questions</h3>
<p className="text-muted-foreground max-w-lg mx-auto">
The AI will analyze all questions and suggest improvements for:
</p>
</div>
<div className="flex gap-4">
<div className="flex items-center gap-2 bg-purple-50 px-4 py-2 rounded-lg">
<Wand2 className="h-5 w-5 text-purple-500" />
<span className="text-sm font-medium">Text Phrasing</span>
</div>
<div className="flex items-center gap-2 bg-indigo-50 px-4 py-2 rounded-lg">
<ShieldCheck className="h-5 w-5 text-indigo-500" />
<span className="text-sm font-medium">Audience Targeting</span>
</div>
</div>
<Button onClick={handleAnalyze} disabled={analyzing} size="lg" className="bg-amber-600 hover:bg-amber-700">
{analyzing ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Analyzing...
</>
) : (
'Start Review'
)}
</Button>
</div>
) : (
<div className="space-y-4">
{suggestions.length === 0 ? (
<div className="text-center py-8 text-muted-foreground">
<Check className="h-8 w-8 mx-auto mb-2 text-green-500" />
All questions look great! No improvements needed.
</div>
) : (
<>
{/* Summary and bulk actions */}
<div className="flex items-center justify-between bg-muted/50 p-3 rounded-lg">
<div className="flex items-center gap-4 text-sm">
<span className="flex items-center gap-1">
<Wand2 className="h-4 w-4 text-purple-500" />
{textSuggestionCount} text fixes
</span>
<span className="flex items-center gap-1">
<ShieldCheck className="h-4 w-4 text-indigo-500" />
{targetSuggestionCount} target fixes
</span>
</div>
<div className="flex items-center gap-2">
<Button variant="ghost" size="sm" onClick={selectAll}>
Select All
</Button>
<Button variant="ghost" size="sm" onClick={selectNone}>
Select None
</Button>
</div>
</div>
<div className="border rounded-md">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-[300px]">Question</TableHead>
<TableHead>
<div className="flex items-center gap-2">
<Wand2 className="h-4 w-4 text-purple-500" />
Text Fix
<Button
variant="ghost"
size="sm"
className="h-6 px-2 text-xs"
onClick={() => selectedTextIds.size === textSuggestionCount ? selectNoneText() : selectAllText()}
>
{selectedTextIds.size === textSuggestionCount ? 'None' : 'All'}
</Button>
</div>
</TableHead>
<TableHead>
<div className="flex items-center gap-2">
<ShieldCheck className="h-4 w-4 text-indigo-500" />
Target Fix
<Button
variant="ghost"
size="sm"
className="h-6 px-2 text-xs"
onClick={() => selectedTargetIds.size === targetSuggestionCount ? selectNoneTargets() : selectAllTargets()}
>
{selectedTargetIds.size === targetSuggestionCount ? 'None' : 'All'}
</Button>
</div>
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{suggestions.map((suggestion) => (
<TableRow key={suggestion.questionId}>
{/* Question Column */}
<TableCell className="align-top">
<div className="text-sm font-medium line-clamp-2">{suggestion.question.text}</div>
{suggestion.question.partner_text && (
<div className="text-xs text-muted-foreground mt-1 italic line-clamp-1">
Partner: {suggestion.question.partner_text}
</div>
)}
</TableCell>
{/* Text Fix Column */}
<TableCell className="align-top">
{suggestion.textSuggestion ? (
<div
className={`p-2 rounded-lg border cursor-pointer transition-colors ${selectedTextIds.has(suggestion.questionId)
? 'bg-purple-50 border-purple-300'
: 'bg-muted/30 border-transparent hover:bg-muted/50'
}`}
onClick={() => toggleTextSelection(suggestion.questionId)}
>
<div className="flex items-start gap-2">
<Checkbox
checked={selectedTextIds.has(suggestion.questionId)}
onCheckedChange={() => toggleTextSelection(suggestion.questionId)}
className="mt-0.5"
/>
<div className="flex-1 min-w-0">
<div className="text-sm text-purple-700 font-medium">
{suggestion.textSuggestion.suggested_text}
</div>
{suggestion.textSuggestion.suggested_partner_text && (
<div className="text-xs text-purple-600 mt-1 italic">
Partner: {suggestion.textSuggestion.suggested_partner_text}
</div>
)}
<div className="text-xs text-muted-foreground mt-1">
{suggestion.textSuggestion.reason}
</div>
</div>
</div>
</div>
) : (
<div className="text-xs text-muted-foreground p-2">
<Check className="h-4 w-4 text-green-500 inline mr-1" />
No changes needed
</div>
)}
</TableCell>
{/* Target Fix Column */}
<TableCell className="align-top">
{suggestion.targetSuggestion ? (
<div
className={`p-2 rounded-lg border cursor-pointer transition-colors ${selectedTargetIds.has(suggestion.questionId)
? 'bg-indigo-50 border-indigo-300'
: 'bg-muted/30 border-transparent hover:bg-muted/50'
}`}
onClick={() => toggleTargetSelection(suggestion.questionId)}
>
<div className="flex items-start gap-2">
<Checkbox
checked={selectedTargetIds.has(suggestion.questionId)}
onCheckedChange={() => toggleTargetSelection(suggestion.questionId)}
className="mt-0.5"
/>
<div className="flex-1 min-w-0 space-y-2">
{/* Couple targets */}
<div className="flex items-center gap-1 flex-wrap">
<Badge variant="outline" className="text-[10px] text-muted-foreground">
{formatTargets(suggestion.question.allowed_couple_genders)}
</Badge>
<ArrowRight className="h-3 w-3 text-muted-foreground" />
<Badge className="text-[10px] bg-indigo-100 text-indigo-700 hover:bg-indigo-100">
{formatTargets(suggestion.targetSuggestion.suggested_targets)}
</Badge>
</div>
{/* Initiator */}
<div className="flex items-center gap-1 flex-wrap">
<Badge variant="outline" className="text-[10px] text-muted-foreground border-orange-300">
{formatInitiator(suggestion.question.target_user_genders)}
</Badge>
<ArrowRight className="h-3 w-3 text-muted-foreground" />
<Badge className="text-[10px] bg-orange-100 text-orange-700 hover:bg-orange-100">
{formatInitiator(suggestion.targetSuggestion.suggested_initiator)}
</Badge>
</div>
<div className="text-xs text-muted-foreground">
{suggestion.targetSuggestion.reason}
</div>
</div>
</div>
</div>
) : (
<div className="text-xs text-muted-foreground p-2">
<Check className="h-4 w-4 text-green-500 inline mr-1" />
No changes needed
</div>
)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</>
)}
</div>
)}
</div>
<DialogFooter>
{step === 'review' && (
<>
<Button variant="outline" onClick={() => setStep('initial')}>
Back
</Button>
<Button
onClick={handleApply}
disabled={applying || suggestions.length === 0 || totalSelectedCount === 0}
className="bg-amber-600 hover:bg-amber-700"
>
{applying ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Applying...
</>
) : (
`Apply ${totalSelectedCount} Changes`
)}
</Button>
</>
)}
</DialogFooter>
</DialogContent>
</Dialog>
);
}
+35
View File
@@ -0,0 +1,35 @@
import * as React from "react";
import { cn } from "@/lib/utils";
import { Check } from "lucide-react";
export interface CheckboxProps
extends Omit<React.InputHTMLAttributes<HTMLInputElement>, 'type' | 'onChange'> {
checked?: boolean;
onCheckedChange?: (checked: boolean) => void;
}
const Checkbox = React.forwardRef<HTMLInputElement, CheckboxProps>(
({ className, checked, onCheckedChange, ...props }, ref) => {
return (
<button
type="button"
role="checkbox"
aria-checked={checked}
className={cn(
"peer h-4 w-4 shrink-0 rounded-sm border border-primary ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
checked ? "bg-primary text-primary-foreground" : "bg-background",
className
)}
onClick={() => onCheckedChange?.(!checked)}
{...props}
>
{checked && (
<Check className="h-3 w-3 mx-auto" strokeWidth={3} />
)}
</button>
);
}
);
Checkbox.displayName = "Checkbox";
export { Checkbox };
+242
View File
@@ -0,0 +1,242 @@
import { supabase } from '@/config';
// Tables to audit
const AUDITED_TABLES = [
'question_packs',
'questions',
'categories',
'profiles',
'couples',
'responses',
'matches',
'messages',
'subscriptions',
'feedback',
'admin_users',
];
type AuditAction = 'INSERT' | 'UPDATE' | 'DELETE';
async function logAction(
tableName: string,
action: AuditAction,
oldValues?: Record<string, unknown> | null,
newValues?: Record<string, unknown> | null
): Promise<void> {
if (!AUDITED_TABLES.includes(tableName)) return;
try {
const { error } = await supabase.rpc('log_admin_action', {
p_table_name: tableName,
p_action: action,
p_old_values: oldValues || null,
p_new_values: newValues || null,
});
if (error) {
console.error('Failed to log audit action:', error);
}
} catch (error) {
console.error('Failed to log audit action:', error);
// Don't throw - logging failure shouldn't block the operation
}
}
interface InsertResult<T> {
data: T[] | null;
error: Error | null;
}
interface SingleResult<T> {
data: T | null;
error: Error | null;
}
/**
* Audited Supabase operations - automatically logs INSERT/UPDATE/DELETE to audit_logs
*/
export const auditedSupabase = {
/**
* Insert records with audit logging
*/
async insert<T extends { id: string }>(
table: string,
records: Omit<T, 'id' | 'created_at'>[] | Omit<T, 'id' | 'created_at'>
): Promise<InsertResult<T>> {
const recordArray = Array.isArray(records) ? records : [records];
const { data, error } = await supabase
.from(table)
.insert(recordArray)
.select();
if (!error && data) {
// Log each inserted record
for (const record of data as T[]) {
await logAction(table, 'INSERT', null, record as Record<string, unknown>);
}
}
return { data: data as T[] | null, error };
},
/**
* Update a single record with audit logging
*/
async update<T extends { id: string }>(
table: string,
id: string,
updates: Partial<T>
): Promise<SingleResult<T>> {
// Fetch old values before update
let oldValues: Record<string, unknown> | null = null;
if (AUDITED_TABLES.includes(table)) {
const { data: oldData } = await supabase
.from(table)
.select('*')
.eq('id', id)
.single();
oldValues = oldData;
}
const { data, error } = await supabase
.from(table)
.update(updates)
.eq('id', id)
.select()
.single();
if (!error && data) {
await logAction(table, 'UPDATE', oldValues, data as Record<string, unknown>);
}
return { data: data as T | null, error };
},
/**
* Update multiple records matching a filter with audit logging
*/
async updateMany<T extends { id: string }>(
table: string,
filter: { column: string; value: unknown },
updates: Partial<T>
): Promise<InsertResult<T>> {
// Fetch old values before update
let oldRecords: Record<string, unknown>[] = [];
if (AUDITED_TABLES.includes(table)) {
const { data: oldData } = await supabase
.from(table)
.select('*')
.eq(filter.column, filter.value);
oldRecords = oldData || [];
}
const { data, error } = await supabase
.from(table)
.update(updates)
.eq(filter.column, filter.value)
.select();
if (!error && data) {
// Log each updated record
for (const record of data as T[]) {
const oldRecord = oldRecords.find(r => r.id === record.id);
await logAction(table, 'UPDATE', oldRecord || null, record as Record<string, unknown>);
}
}
return { data: data as T[] | null, error };
},
/**
* Delete a single record with audit logging
*/
async delete(
table: string,
id: string
): Promise<{ error: Error | null }> {
// Fetch old values before delete
let oldValues: Record<string, unknown> | null = null;
if (AUDITED_TABLES.includes(table)) {
const { data: oldData } = await supabase
.from(table)
.select('*')
.eq('id', id)
.single();
oldValues = oldData;
}
const { error } = await supabase
.from(table)
.delete()
.eq('id', id);
if (!error) {
await logAction(table, 'DELETE', oldValues, null);
}
return { error };
},
/**
* Delete multiple records with audit logging
*/
async deleteMany(
table: string,
ids: string[]
): Promise<{ error: Error | null }> {
// Fetch old values before delete
let oldRecords: Record<string, unknown>[] = [];
if (AUDITED_TABLES.includes(table)) {
const { data: oldData } = await supabase
.from(table)
.select('*')
.in('id', ids);
oldRecords = oldData || [];
}
const { error } = await supabase
.from(table)
.delete()
.in('id', ids);
if (!error) {
// Log each deletion
for (const record of oldRecords) {
await logAction(table, 'DELETE', record, null);
}
}
return { error };
},
/**
* Delete a record by a custom field (not id) with audit logging
*/
async deleteBy(
table: string,
field: string,
value: string
): Promise<{ error: Error | null }> {
// Fetch old values before delete
let oldValues: Record<string, unknown> | null = null;
if (AUDITED_TABLES.includes(table)) {
const { data: oldData } = await supabase
.from(table)
.select('*')
.eq(field, value)
.single();
oldValues = oldData;
}
const { error } = await supabase
.from(table)
.delete()
.eq(field, value);
if (!error && oldValues) {
await logAction(table, 'DELETE', oldValues, null);
}
return { error };
},
};