mirror of
https://github.com/pacnpal/thrilltrack-explorer.git
synced 2025-12-28 15:47:01 -05:00
Compare commits
24 Commits
d18632c2b2
...
edit/edt-5
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6087b54213 | ||
|
|
68384156ab | ||
|
|
5cc5d3eab6 | ||
|
|
706e36c847 | ||
|
|
a1beba6996 | ||
|
|
d7158756ef | ||
|
|
3330a8fac9 | ||
|
|
c09a343d08 | ||
|
|
9893567a30 | ||
|
|
771405961f | ||
|
|
437e2b353c | ||
|
|
44a713af62 | ||
|
|
46275e0f1e | ||
|
|
6bd7d24a1b | ||
|
|
72e76e86af | ||
|
|
a35486fb11 | ||
|
|
3d3ae57ee3 | ||
|
|
46c08e10e8 | ||
|
|
b22546e7f2 | ||
|
|
7b0825e772 | ||
|
|
1a57b4f33f | ||
|
|
4c7731410f | ||
|
|
beacf481d8 | ||
|
|
00054f817d |
11
src/App.tsx
11
src/App.tsx
@@ -79,6 +79,7 @@ const ErrorLookup = lazy(() => import("./pages/admin/ErrorLookup"));
|
||||
const TraceViewer = lazy(() => import("./pages/admin/TraceViewer"));
|
||||
const RateLimitMetrics = lazy(() => import("./pages/admin/RateLimitMetrics"));
|
||||
const MonitoringOverview = lazy(() => import("./pages/admin/MonitoringOverview"));
|
||||
const ApprovalHistory = lazy(() => import("./pages/admin/ApprovalHistory"));
|
||||
|
||||
// User routes (lazy-loaded)
|
||||
const Profile = lazy(() => import("./pages/Profile"));
|
||||
@@ -387,7 +388,15 @@ function AppContent(): React.JSX.Element {
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/admin/error-lookup"
|
||||
path="/admin/approval-history"
|
||||
element={
|
||||
<AdminErrorBoundary section="Approval History">
|
||||
<ApprovalHistory />
|
||||
</AdminErrorBoundary>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/admin/error-lookup"
|
||||
element={
|
||||
<AdminErrorBoundary section="Error Lookup">
|
||||
<ErrorLookup />
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { Filter, MessageSquare, FileText, Image } from 'lucide-react';
|
||||
import { Filter, MessageSquare, FileText, Image, Calendar } from 'lucide-react';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import type { EntityFilter, StatusFilter } from '@/types/moderation';
|
||||
import { format } from 'date-fns';
|
||||
import type { EntityFilter, StatusFilter, ApprovalDateRangeFilter } from '@/types/moderation';
|
||||
|
||||
interface ActiveFiltersDisplayProps {
|
||||
entityFilter: EntityFilter;
|
||||
statusFilter: StatusFilter;
|
||||
approvalDateRange?: ApprovalDateRangeFilter;
|
||||
defaultEntityFilter?: EntityFilter;
|
||||
defaultStatusFilter?: StatusFilter;
|
||||
}
|
||||
@@ -23,12 +25,15 @@ const getEntityFilterIcon = (filter: EntityFilter) => {
|
||||
export const ActiveFiltersDisplay = ({
|
||||
entityFilter,
|
||||
statusFilter,
|
||||
approvalDateRange,
|
||||
defaultEntityFilter = 'all',
|
||||
defaultStatusFilter = 'pending'
|
||||
}: ActiveFiltersDisplayProps) => {
|
||||
const hasDateRange = approvalDateRange && (approvalDateRange.from || approvalDateRange.to);
|
||||
const hasActiveFilters =
|
||||
entityFilter !== defaultEntityFilter ||
|
||||
statusFilter !== defaultStatusFilter;
|
||||
statusFilter !== defaultStatusFilter ||
|
||||
hasDateRange;
|
||||
|
||||
if (!hasActiveFilters) return null;
|
||||
|
||||
@@ -46,6 +51,14 @@ export const ActiveFiltersDisplay = ({
|
||||
{statusFilter}
|
||||
</Badge>
|
||||
)}
|
||||
{hasDateRange && (
|
||||
<Badge variant="secondary" className="flex items-center gap-1">
|
||||
<Calendar className="w-3 h-3" />
|
||||
{approvalDateRange.from && format(approvalDateRange.from, 'MMM d')}
|
||||
{approvalDateRange.from && approvalDateRange.to && ' - '}
|
||||
{approvalDateRange.to && format(approvalDateRange.to, 'MMM d')}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
78
src/components/moderation/DetailedViewCollapsible.tsx
Normal file
78
src/components/moderation/DetailedViewCollapsible.tsx
Normal file
@@ -0,0 +1,78 @@
|
||||
import { ChevronDown } from 'lucide-react';
|
||||
import { Collapsible, CollapsibleTrigger, CollapsibleContent } from '@/components/ui/collapsible';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface DetailedViewCollapsibleProps {
|
||||
isCollapsed: boolean;
|
||||
onToggle: () => void;
|
||||
children: React.ReactNode;
|
||||
fieldCount?: number;
|
||||
className?: string;
|
||||
staggerIndex?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Collapsible wrapper for detailed field-by-field view sections
|
||||
* Provides expand/collapse functionality with visual indicators
|
||||
*/
|
||||
export function DetailedViewCollapsible({
|
||||
isCollapsed,
|
||||
onToggle,
|
||||
children,
|
||||
fieldCount,
|
||||
className,
|
||||
staggerIndex = 0
|
||||
}: DetailedViewCollapsibleProps) {
|
||||
// Calculate stagger delay: 50ms per item, max 300ms
|
||||
const staggerDelay = Math.min(staggerIndex * 50, 300);
|
||||
return (
|
||||
<Collapsible open={!isCollapsed} onOpenChange={() => onToggle()}>
|
||||
<div className={cn("mt-6 pt-6 border-t", className)}>
|
||||
<CollapsibleTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="w-full flex items-center justify-between hover:bg-muted/50 p-2 h-auto transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs font-semibold text-muted-foreground uppercase tracking-wide">
|
||||
All Fields (Detailed View)
|
||||
</span>
|
||||
{fieldCount !== undefined && fieldCount > 0 && (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="h-5 px-1.5 text-xs font-normal transition-transform duration-200 hover:scale-105"
|
||||
>
|
||||
{fieldCount}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-muted-foreground normal-case font-normal">
|
||||
{isCollapsed ? 'Show' : 'Hide'}
|
||||
</span>
|
||||
<ChevronDown
|
||||
className={cn(
|
||||
"h-4 w-4 text-muted-foreground transition-all duration-300 ease-out",
|
||||
!isCollapsed && "rotate-180"
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</Button>
|
||||
</CollapsibleTrigger>
|
||||
|
||||
<CollapsibleContent
|
||||
className="mt-3"
|
||||
style={{
|
||||
animationDelay: `${staggerDelay}ms`,
|
||||
transitionDelay: `${staggerDelay}ms`
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</CollapsibleContent>
|
||||
</div>
|
||||
</Collapsible>
|
||||
);
|
||||
}
|
||||
321
src/components/moderation/ItemApprovalHistory.tsx
Normal file
321
src/components/moderation/ItemApprovalHistory.tsx
Normal file
@@ -0,0 +1,321 @@
|
||||
/**
|
||||
* Item Approval History Component
|
||||
*
|
||||
* Displays detailed audit trail of approved items with exact timestamps.
|
||||
* Features filtering, sorting, CSV export for compliance reporting.
|
||||
*/
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { supabase } from '@/integrations/supabase/client';
|
||||
import { format } from 'date-fns';
|
||||
import { ExternalLink, Download, Clock, User, FileText } from 'lucide-react';
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
||||
import { handleError } from '@/lib/errorHandler';
|
||||
import type { EntityType } from '@/types/submissions';
|
||||
|
||||
interface ApprovalHistoryItem {
|
||||
item_id: string;
|
||||
submission_id: string;
|
||||
item_type: string;
|
||||
action_type: string;
|
||||
status: string;
|
||||
approved_at: string;
|
||||
approved_entity_id: string;
|
||||
created_at: string;
|
||||
approval_time_seconds: number;
|
||||
submission_type: string;
|
||||
submitter_username: string | null;
|
||||
submitter_display_name: string | null;
|
||||
submitter_avatar_url: string | null;
|
||||
approver_username: string | null;
|
||||
approver_display_name: string | null;
|
||||
approver_avatar_url: string | null;
|
||||
entity_slug: string | null;
|
||||
entity_name: string | null;
|
||||
}
|
||||
|
||||
interface ItemApprovalHistoryProps {
|
||||
submissionId?: string;
|
||||
dateRange?: { from: Date; to: Date };
|
||||
itemType?: EntityType;
|
||||
limit?: number;
|
||||
embedded?: boolean;
|
||||
}
|
||||
|
||||
const getApprovalSpeed = (seconds: number) => {
|
||||
const hours = seconds / 3600;
|
||||
if (hours < 1) return { label: 'Fast', variant: 'default' as const, color: 'text-green-600 dark:text-green-400' };
|
||||
if (hours < 24) return { label: 'Normal', variant: 'secondary' as const, color: 'text-blue-600 dark:text-blue-400' };
|
||||
return { label: 'Slow', variant: 'destructive' as const, color: 'text-orange-600 dark:text-orange-400' };
|
||||
};
|
||||
|
||||
const formatDuration = (seconds: number) => {
|
||||
const hours = Math.floor(seconds / 3600);
|
||||
const minutes = Math.floor((seconds % 3600) / 60);
|
||||
|
||||
if (hours > 48) {
|
||||
const days = Math.floor(hours / 24);
|
||||
return `${days}d ${hours % 24}h`;
|
||||
}
|
||||
if (hours > 0) return `${hours}h ${minutes}m`;
|
||||
return `${minutes}m`;
|
||||
};
|
||||
|
||||
const getEntityPath = (itemType: string, slug: string | null) => {
|
||||
if (!slug) return null;
|
||||
|
||||
switch (itemType) {
|
||||
case 'park': return `/parks/${slug}/`;
|
||||
case 'ride': return `/rides/${slug}`; // Need park slug ideally
|
||||
case 'manufacturer':
|
||||
case 'designer':
|
||||
case 'operator':
|
||||
return `/companies/${slug}/`;
|
||||
case 'ride_model': return `/models/${slug}/`;
|
||||
default: return null;
|
||||
}
|
||||
};
|
||||
|
||||
export const ItemApprovalHistory = ({
|
||||
submissionId,
|
||||
dateRange,
|
||||
itemType,
|
||||
limit = 100,
|
||||
embedded = false
|
||||
}: ItemApprovalHistoryProps) => {
|
||||
const [sortField, setSortField] = useState<'approved_at' | 'approval_time_seconds'>('approved_at');
|
||||
const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('desc');
|
||||
|
||||
const { data: history, isLoading, error } = useQuery({
|
||||
queryKey: ['approval-history', { submissionId, dateRange, itemType, limit }],
|
||||
queryFn: async () => {
|
||||
try {
|
||||
const { data, error } = await supabase.rpc('get_approval_history', {
|
||||
p_item_type: itemType || undefined,
|
||||
p_approver_id: undefined,
|
||||
p_from_date: dateRange?.from?.toISOString() || undefined,
|
||||
p_to_date: dateRange?.to?.toISOString() || undefined,
|
||||
p_limit: limit,
|
||||
p_offset: 0
|
||||
});
|
||||
|
||||
if (error) throw error;
|
||||
|
||||
// Client-side filter by submission_id if provided
|
||||
let filtered = data as ApprovalHistoryItem[];
|
||||
if (submissionId) {
|
||||
filtered = filtered.filter(item => item.submission_id === submissionId);
|
||||
}
|
||||
|
||||
return filtered;
|
||||
} catch (err: unknown) {
|
||||
handleError(err, { action: 'fetch_approval_history' });
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
staleTime: 5 * 60 * 1000, // 5 minutes
|
||||
});
|
||||
|
||||
const sortedHistory = history ? [...history].sort((a, b) => {
|
||||
const aVal = a[sortField];
|
||||
const bVal = b[sortField];
|
||||
const comparison = aVal < bVal ? -1 : aVal > bVal ? 1 : 0;
|
||||
return sortDirection === 'asc' ? comparison : -comparison;
|
||||
}) : [];
|
||||
|
||||
const handleSort = (field: typeof sortField) => {
|
||||
if (sortField === field) {
|
||||
setSortDirection(prev => prev === 'asc' ? 'desc' : 'asc');
|
||||
} else {
|
||||
setSortField(field);
|
||||
setSortDirection('desc');
|
||||
}
|
||||
};
|
||||
|
||||
const exportToCSV = () => {
|
||||
if (!history || history.length === 0) return;
|
||||
|
||||
const headers = [
|
||||
'Timestamp',
|
||||
'Item Type',
|
||||
'Action',
|
||||
'Entity Name',
|
||||
'Submitter',
|
||||
'Approver',
|
||||
'Time to Approve (hours)',
|
||||
'Submission ID',
|
||||
'Item ID'
|
||||
];
|
||||
|
||||
const rows = history.map(item => [
|
||||
format(new Date(item.approved_at), 'yyyy-MM-dd HH:mm:ss'),
|
||||
item.item_type,
|
||||
item.action_type,
|
||||
item.entity_name || 'N/A',
|
||||
item.submitter_display_name || item.submitter_username || 'Unknown',
|
||||
item.approver_display_name || item.approver_username || 'Unknown',
|
||||
(item.approval_time_seconds / 3600).toFixed(2),
|
||||
item.submission_id,
|
||||
item.item_id
|
||||
]);
|
||||
|
||||
const csv = [headers, ...rows].map(row => row.map(cell => `"${cell}"`).join(',')).join('\n');
|
||||
const blob = new Blob([csv], { type: 'text/csv' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = `approval-history-${format(new Date(), 'yyyy-MM-dd')}.csv`;
|
||||
link.click();
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<Card className={embedded ? '' : 'mt-6'}>
|
||||
<CardContent className="pt-6">
|
||||
<p className="text-sm text-destructive">Failed to load approval history. Please try again.</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
const content = (
|
||||
<>
|
||||
{!embedded && (
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<CardTitle>Item Approval History</CardTitle>
|
||||
<CardDescription>Detailed audit trail of approved submissions</CardDescription>
|
||||
</div>
|
||||
{sortedHistory.length > 0 && (
|
||||
<Button onClick={exportToCSV} variant="outline" size="sm">
|
||||
<Download className="w-4 h-4 mr-2" />
|
||||
Export CSV
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</CardHeader>
|
||||
)}
|
||||
<CardContent className={embedded ? 'p-0' : ''}>
|
||||
{isLoading ? (
|
||||
<div className="space-y-3">
|
||||
{[...Array(5)].map((_, i) => (
|
||||
<Skeleton key={i} className="h-16 w-full" />
|
||||
))}
|
||||
</div>
|
||||
) : sortedHistory.length === 0 ? (
|
||||
<div className="text-center py-12 text-muted-foreground">
|
||||
<FileText className="w-12 h-12 mx-auto mb-3 opacity-50" />
|
||||
<p>No approval history found</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-md border overflow-x-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead
|
||||
className="cursor-pointer hover:bg-muted/50"
|
||||
onClick={() => handleSort('approved_at')}
|
||||
>
|
||||
Approved At {sortField === 'approved_at' && (sortDirection === 'asc' ? '↑' : '↓')}
|
||||
</TableHead>
|
||||
<TableHead>Type</TableHead>
|
||||
<TableHead>Entity</TableHead>
|
||||
<TableHead>Submitter</TableHead>
|
||||
<TableHead>Approver</TableHead>
|
||||
<TableHead
|
||||
className="cursor-pointer hover:bg-muted/50 text-right"
|
||||
onClick={() => handleSort('approval_time_seconds')}
|
||||
>
|
||||
Time to Approve {sortField === 'approval_time_seconds' && (sortDirection === 'asc' ? '↑' : '↓')}
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{sortedHistory.map((item) => {
|
||||
const speed = getApprovalSpeed(item.approval_time_seconds);
|
||||
const entityPath = getEntityPath(item.item_type, item.entity_slug);
|
||||
|
||||
return (
|
||||
<TableRow key={item.item_id}>
|
||||
<TableCell className="font-mono text-xs">
|
||||
<div className="flex flex-col">
|
||||
<span>{format(new Date(item.approved_at), 'MMM d, yyyy')}</span>
|
||||
<span className="text-muted-foreground">{format(new Date(item.approved_at), 'HH:mm:ss')}</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="outline" className="capitalize">
|
||||
{item.item_type}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{item.entity_name ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium">{item.entity_name}</span>
|
||||
{entityPath && (
|
||||
<a
|
||||
href={entityPath}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary hover:underline"
|
||||
>
|
||||
<ExternalLink className="w-3 h-3" />
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-muted-foreground text-sm">N/A</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-2">
|
||||
<Avatar className="h-6 w-6">
|
||||
<AvatarImage src={item.submitter_avatar_url || undefined} />
|
||||
<AvatarFallback className="text-xs">
|
||||
{(item.submitter_display_name || item.submitter_username || 'U')[0].toUpperCase()}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<span className="text-sm">{item.submitter_display_name || item.submitter_username || 'Unknown'}</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-2">
|
||||
<Avatar className="h-6 w-6">
|
||||
<AvatarImage src={item.approver_avatar_url || undefined} />
|
||||
<AvatarFallback className="text-xs">
|
||||
{(item.approver_display_name || item.approver_username || 'M')[0].toUpperCase()}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<span className="text-sm">{item.approver_display_name || item.approver_username || 'Unknown'}</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<Clock className={`w-4 h-4 ${speed.color}`} />
|
||||
<span className="font-mono text-sm">{formatDuration(item.approval_time_seconds)}</span>
|
||||
<Badge variant={speed.variant} className="ml-1">
|
||||
{speed.label}
|
||||
</Badge>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</>
|
||||
);
|
||||
|
||||
return embedded ? content : <Card className="mt-6">{content}</Card>;
|
||||
};
|
||||
125
src/components/moderation/ItemLevelApprovalHistory.tsx
Normal file
125
src/components/moderation/ItemLevelApprovalHistory.tsx
Normal file
@@ -0,0 +1,125 @@
|
||||
import { memo } from 'react';
|
||||
import { formatDistanceToNow } from 'date-fns';
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { CheckCircle2, User } from 'lucide-react';
|
||||
import type { SubmissionItem } from '@/types/moderation';
|
||||
|
||||
interface ItemLevelApprovalHistoryProps {
|
||||
items: SubmissionItem[];
|
||||
reviewerProfile?: {
|
||||
user_id: string;
|
||||
username: string;
|
||||
display_name?: string | null;
|
||||
avatar_url?: string | null;
|
||||
} | null;
|
||||
}
|
||||
|
||||
export const ItemLevelApprovalHistory = memo(({
|
||||
items,
|
||||
reviewerProfile,
|
||||
}: ItemLevelApprovalHistoryProps) => {
|
||||
// Filter to only approved items with timestamps
|
||||
const approvedItems = items.filter(
|
||||
item => item.status === 'approved' && (item as any).approved_at
|
||||
);
|
||||
|
||||
if (approvedItems.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Sort by approval time (newest first)
|
||||
const sortedItems = [...approvedItems].sort((a, b) => {
|
||||
const timeA = new Date((a as any).approved_at).getTime();
|
||||
const timeB = new Date((b as any).approved_at).getTime();
|
||||
return timeB - timeA;
|
||||
});
|
||||
|
||||
// Helper to get item display name
|
||||
const getItemName = (item: SubmissionItem): string => {
|
||||
const entityData = item.entity_data || item.item_data;
|
||||
if (entityData && typeof entityData === 'object' && 'name' in entityData) {
|
||||
return String(entityData.name);
|
||||
}
|
||||
return `${item.item_type} #${item.order_index}`;
|
||||
};
|
||||
|
||||
// Helper to get action label
|
||||
const getActionLabel = (actionType: string): string => {
|
||||
switch (actionType) {
|
||||
case 'create': return 'Created';
|
||||
case 'edit': return 'Edited';
|
||||
case 'delete': return 'Deleted';
|
||||
default: return 'Modified';
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="text-xs font-semibold text-muted-foreground uppercase tracking-wide flex items-center gap-2">
|
||||
<CheckCircle2 className="h-3.5 w-3.5" />
|
||||
Item Approvals
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
{sortedItems.map((item) => {
|
||||
const approvedAt = (item as any).approved_at;
|
||||
const itemName = getItemName(item);
|
||||
const actionLabel = getActionLabel(item.action_type);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={item.id}
|
||||
className="flex items-start gap-3 text-sm bg-success/5 border border-success/20 rounded-md p-3"
|
||||
>
|
||||
{/* Approval Icon */}
|
||||
<div className="flex-shrink-0 mt-0.5">
|
||||
<CheckCircle2 className="h-4 w-4 text-success" />
|
||||
</div>
|
||||
|
||||
{/* Item Info */}
|
||||
<div className="flex-1 min-w-0 space-y-1">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="font-medium text-foreground truncate">
|
||||
{itemName}
|
||||
</span>
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{actionLabel}
|
||||
</Badge>
|
||||
<Badge variant="secondary" className="text-xs font-mono">
|
||||
{item.item_type}
|
||||
</Badge>
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground whitespace-nowrap">
|
||||
{formatDistanceToNow(new Date(approvedAt), { addSuffix: true })}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Reviewer Info */}
|
||||
{reviewerProfile && (
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<Avatar className="h-5 w-5">
|
||||
<AvatarImage src={reviewerProfile.avatar_url ?? undefined} />
|
||||
<AvatarFallback className="text-[10px]">
|
||||
<User className="h-3 w-3" />
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<span>
|
||||
Approved by{' '}
|
||||
<span className="font-medium text-foreground">
|
||||
{reviewerProfile.display_name || reviewerProfile.username}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
ItemLevelApprovalHistory.displayName = 'ItemLevelApprovalHistory';
|
||||
@@ -501,11 +501,14 @@ export const ModerationQueue = forwardRef<ModerationQueueRef, ModerationQueuePro
|
||||
activeEntityFilter={queueManager.filters.entityFilter}
|
||||
activeStatusFilter={queueManager.filters.statusFilter}
|
||||
sortConfig={queueManager.filters.sortConfig}
|
||||
activeTab={queueManager.filters.activeTab}
|
||||
approvalDateRange={queueManager.filters.approvalDateRange}
|
||||
isMobile={isMobile ?? false}
|
||||
isLoading={queueManager.loadingState === 'loading'}
|
||||
onEntityFilterChange={queueManager.filters.setEntityFilter}
|
||||
onStatusFilterChange={queueManager.filters.setStatusFilter}
|
||||
onSortChange={queueManager.filters.setSortConfig}
|
||||
onApprovalDateRangeChange={queueManager.filters.setApprovalDateRange}
|
||||
onClearFilters={queueManager.filters.clearFilters}
|
||||
showClearButton={queueManager.filters.hasActiveFilters}
|
||||
onRefresh={queueManager.refresh}
|
||||
@@ -517,6 +520,7 @@ export const ModerationQueue = forwardRef<ModerationQueueRef, ModerationQueuePro
|
||||
<ActiveFiltersDisplay
|
||||
entityFilter={queueManager.filters.entityFilter}
|
||||
statusFilter={queueManager.filters.statusFilter}
|
||||
approvalDateRange={queueManager.filters.approvalDateRange}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -1,23 +1,29 @@
|
||||
import { Filter, MessageSquare, FileText, Image, X, ChevronDown } from 'lucide-react';
|
||||
import { Filter, MessageSquare, FileText, Image, X, ChevronDown, Calendar, Maximize2, Minimize2 } from 'lucide-react';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
import { RefreshButton } from '@/components/ui/refresh-button';
|
||||
import { QueueSortControls } from './QueueSortControls';
|
||||
import { useFilterPanelState } from '@/hooks/useFilterPanelState';
|
||||
import type { EntityFilter, StatusFilter, SortConfig } from '@/types/moderation';
|
||||
import { useDetailedViewState } from '@/hooks/useDetailedViewState';
|
||||
import { FilterDateRangePicker } from '@/components/filters/FilterDateRangePicker';
|
||||
import type { EntityFilter, StatusFilter, SortConfig, QueueTab, ApprovalDateRangeFilter } from '@/types/moderation';
|
||||
|
||||
interface QueueFiltersProps {
|
||||
activeEntityFilter: EntityFilter;
|
||||
activeStatusFilter: StatusFilter;
|
||||
sortConfig: SortConfig;
|
||||
activeTab: QueueTab;
|
||||
approvalDateRange: ApprovalDateRangeFilter;
|
||||
isMobile: boolean;
|
||||
isLoading?: boolean;
|
||||
onEntityFilterChange: (filter: EntityFilter) => void;
|
||||
onStatusFilterChange: (filter: StatusFilter) => void;
|
||||
onSortChange: (config: SortConfig) => void;
|
||||
onApprovalDateRangeChange: (range: ApprovalDateRangeFilter) => void;
|
||||
onClearFilters: () => void;
|
||||
showClearButton: boolean;
|
||||
onRefresh?: () => void;
|
||||
@@ -37,22 +43,27 @@ export const QueueFilters = ({
|
||||
activeEntityFilter,
|
||||
activeStatusFilter,
|
||||
sortConfig,
|
||||
activeTab,
|
||||
approvalDateRange,
|
||||
isMobile,
|
||||
isLoading = false,
|
||||
onEntityFilterChange,
|
||||
onStatusFilterChange,
|
||||
onSortChange,
|
||||
onApprovalDateRangeChange,
|
||||
onClearFilters,
|
||||
showClearButton,
|
||||
onRefresh,
|
||||
isRefreshing = false
|
||||
}: QueueFiltersProps) => {
|
||||
const { isCollapsed, toggle } = useFilterPanelState();
|
||||
const { isCollapsed: detailsCollapsed, toggle: toggleDetails } = useDetailedViewState();
|
||||
|
||||
// Count active filters
|
||||
const activeFilterCount = [
|
||||
activeEntityFilter !== 'all' ? 1 : 0,
|
||||
activeStatusFilter !== 'all' ? 1 : 0,
|
||||
approvalDateRange.from || approvalDateRange.to ? 1 : 0,
|
||||
].reduce((sum, val) => sum + val, 0);
|
||||
|
||||
return (
|
||||
@@ -68,14 +79,51 @@ export const QueueFilters = ({
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
{isMobile && (
|
||||
<CollapsibleTrigger asChild>
|
||||
<Button variant="ghost" size="sm" className="h-8 w-8 p-0">
|
||||
<ChevronDown className={`h-4 w-4 transition-transform duration-250 ${isCollapsed ? '' : 'rotate-180'}`} />
|
||||
<span className="sr-only">{isCollapsed ? 'Expand filters' : 'Collapse filters'}</span>
|
||||
</Button>
|
||||
</CollapsibleTrigger>
|
||||
)}
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Global toggle for detailed views */}
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={toggleDetails}
|
||||
className="h-8 gap-2 text-xs text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
{detailsCollapsed ? (
|
||||
<>
|
||||
<Maximize2 className="h-3.5 w-3.5" />
|
||||
{!isMobile && <span>Expand All</span>}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Minimize2 className="h-3.5 w-3.5" />
|
||||
{!isMobile && <span>Collapse All</span>}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" className="max-w-xs">
|
||||
<p className="text-xs">
|
||||
{detailsCollapsed
|
||||
? "Show detailed field-by-field view for all items in the queue"
|
||||
: "Hide detailed field-by-field view for all items in the queue"}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
This preference is saved to your account
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
{isMobile && (
|
||||
<CollapsibleTrigger asChild>
|
||||
<Button variant="ghost" size="sm" className="h-8 w-8 p-0">
|
||||
<ChevronDown className={`h-4 w-4 transition-transform duration-250 ${isCollapsed ? '' : 'rotate-180'}`} />
|
||||
<span className="sr-only">{isCollapsed ? 'Expand filters' : 'Collapse filters'}</span>
|
||||
</Button>
|
||||
</CollapsibleTrigger>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<CollapsibleContent className="space-y-4">
|
||||
@@ -164,6 +212,21 @@ export const QueueFilters = ({
|
||||
isMobile={isMobile}
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
|
||||
{/* Approval Date Range Filter - Only show on archive tab */}
|
||||
{activeTab === 'archive' && (
|
||||
<div className={`space-y-2 ${isMobile ? 'w-full' : 'min-w-[280px]'}`}>
|
||||
<FilterDateRangePicker
|
||||
label="Approved Between"
|
||||
fromDate={approvalDateRange.from}
|
||||
toDate={approvalDateRange.to}
|
||||
onFromChange={(date) => onApprovalDateRangeChange({ ...approvalDateRange, from: date || null })}
|
||||
onToChange={(date) => onApprovalDateRangeChange({ ...approvalDateRange, to: date || null })}
|
||||
fromPlaceholder="Start Date"
|
||||
toPlaceholder="End Date"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Clear Filters & Apply Buttons (mobile only) */}
|
||||
|
||||
@@ -23,6 +23,7 @@ import { QueueItemActions } from './renderers/QueueItemActions';
|
||||
import { SubmissionMetadataPanel } from './SubmissionMetadataPanel';
|
||||
import { AuditTrailViewer } from './AuditTrailViewer';
|
||||
import { RawDataViewer } from './RawDataViewer';
|
||||
import { ItemLevelApprovalHistory } from './ItemLevelApprovalHistory';
|
||||
|
||||
interface QueueItemProps {
|
||||
item: ModerationItem;
|
||||
@@ -330,6 +331,15 @@ export const QueueItem = memo(({
|
||||
{item.type === 'content_submission' && (
|
||||
<div className="mt-6 space-y-4">
|
||||
<SubmissionMetadataPanel item={item} />
|
||||
|
||||
{/* Item-level approval history */}
|
||||
{item.submission_items && item.submission_items.length > 0 && (
|
||||
<ItemLevelApprovalHistory
|
||||
items={item.submission_items}
|
||||
reviewerProfile={item.reviewer_profile}
|
||||
/>
|
||||
)}
|
||||
|
||||
<AuditTrailViewer submissionId={item.id} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -7,6 +7,7 @@ import { RichRideDisplay } from './displays/RichRideDisplay';
|
||||
import { RichCompanyDisplay } from './displays/RichCompanyDisplay';
|
||||
import { RichRideModelDisplay } from './displays/RichRideModelDisplay';
|
||||
import { RichTimelineEventDisplay } from './displays/RichTimelineEventDisplay';
|
||||
import { DetailedViewCollapsible } from './DetailedViewCollapsible';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
@@ -17,6 +18,7 @@ import type { ParkSubmissionData, RideSubmissionData, CompanySubmissionData, Rid
|
||||
import type { TimelineSubmissionData } from '@/types/timeline';
|
||||
import { getErrorMessage, handleNonCriticalError } from '@/lib/errorHandler';
|
||||
import { ModerationErrorBoundary } from '@/components/error/ModerationErrorBoundary';
|
||||
import { useDetailedViewState } from '@/hooks/useDetailedViewState';
|
||||
|
||||
interface SubmissionItemsListProps {
|
||||
submissionId: string;
|
||||
@@ -34,11 +36,18 @@ export const SubmissionItemsList = memo(function SubmissionItemsList({
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const { isCollapsed, toggle } = useDetailedViewState();
|
||||
|
||||
useEffect(() => {
|
||||
fetchSubmissionItems();
|
||||
}, [submissionId]);
|
||||
|
||||
// Helper function to count non-null fields in entity data
|
||||
const countFields = (data: any): number => {
|
||||
if (!data || typeof data !== 'object') return 0;
|
||||
return Object.values(data).filter(value => value !== null && value !== undefined).length;
|
||||
};
|
||||
|
||||
const fetchSubmissionItems = async () => {
|
||||
try {
|
||||
// Only show skeleton on initial load, show refreshing indicator on refresh
|
||||
@@ -126,7 +135,7 @@ export const SubmissionItemsList = memo(function SubmissionItemsList({
|
||||
}
|
||||
|
||||
// Render item with appropriate display component
|
||||
const renderItem = (item: SubmissionItemData) => {
|
||||
const renderItem = (item: SubmissionItemData, index: number = 0) => {
|
||||
// SubmissionItemData from submissions.ts has item_data property
|
||||
const entityData = item.item_data;
|
||||
const actionType = item.action_type || 'create';
|
||||
@@ -188,17 +197,19 @@ export const SubmissionItemsList = memo(function SubmissionItemsList({
|
||||
data={entityData as unknown as ParkSubmissionData}
|
||||
actionType={actionType}
|
||||
/>
|
||||
<div className="mt-6 pt-6 border-t">
|
||||
<div className="text-xs font-semibold text-muted-foreground uppercase tracking-wide mb-3">
|
||||
All Fields (Detailed View)
|
||||
</div>
|
||||
<DetailedViewCollapsible
|
||||
isCollapsed={isCollapsed}
|
||||
onToggle={toggle}
|
||||
fieldCount={countFields(entityData)}
|
||||
staggerIndex={index}
|
||||
>
|
||||
<SubmissionChangesDisplay
|
||||
item={item}
|
||||
view="detailed"
|
||||
showImages={showImages}
|
||||
submissionId={submissionId}
|
||||
/>
|
||||
</div>
|
||||
</DetailedViewCollapsible>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -211,17 +222,19 @@ export const SubmissionItemsList = memo(function SubmissionItemsList({
|
||||
data={entityData as unknown as RideSubmissionData}
|
||||
actionType={actionType}
|
||||
/>
|
||||
<div className="mt-6 pt-6 border-t">
|
||||
<div className="text-xs font-semibold text-muted-foreground uppercase tracking-wide mb-3">
|
||||
All Fields (Detailed View)
|
||||
</div>
|
||||
<DetailedViewCollapsible
|
||||
isCollapsed={isCollapsed}
|
||||
onToggle={toggle}
|
||||
fieldCount={countFields(entityData)}
|
||||
staggerIndex={index}
|
||||
>
|
||||
<SubmissionChangesDisplay
|
||||
item={item}
|
||||
view="detailed"
|
||||
showImages={showImages}
|
||||
submissionId={submissionId}
|
||||
/>
|
||||
</div>
|
||||
</DetailedViewCollapsible>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -234,17 +247,19 @@ export const SubmissionItemsList = memo(function SubmissionItemsList({
|
||||
data={entityData as unknown as CompanySubmissionData}
|
||||
actionType={actionType}
|
||||
/>
|
||||
<div className="mt-6 pt-6 border-t">
|
||||
<div className="text-xs font-semibold text-muted-foreground uppercase tracking-wide mb-3">
|
||||
All Fields (Detailed View)
|
||||
</div>
|
||||
<DetailedViewCollapsible
|
||||
isCollapsed={isCollapsed}
|
||||
onToggle={toggle}
|
||||
fieldCount={countFields(entityData)}
|
||||
staggerIndex={index}
|
||||
>
|
||||
<SubmissionChangesDisplay
|
||||
item={item}
|
||||
view="detailed"
|
||||
showImages={showImages}
|
||||
submissionId={submissionId}
|
||||
/>
|
||||
</div>
|
||||
</DetailedViewCollapsible>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -257,17 +272,19 @@ export const SubmissionItemsList = memo(function SubmissionItemsList({
|
||||
data={entityData as unknown as RideModelSubmissionData}
|
||||
actionType={actionType}
|
||||
/>
|
||||
<div className="mt-6 pt-6 border-t">
|
||||
<div className="text-xs font-semibold text-muted-foreground uppercase tracking-wide mb-3">
|
||||
All Fields (Detailed View)
|
||||
</div>
|
||||
<DetailedViewCollapsible
|
||||
isCollapsed={isCollapsed}
|
||||
onToggle={toggle}
|
||||
fieldCount={countFields(entityData)}
|
||||
staggerIndex={index}
|
||||
>
|
||||
<SubmissionChangesDisplay
|
||||
item={item}
|
||||
view="detailed"
|
||||
showImages={showImages}
|
||||
submissionId={submissionId}
|
||||
/>
|
||||
</div>
|
||||
</DetailedViewCollapsible>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -280,17 +297,19 @@ export const SubmissionItemsList = memo(function SubmissionItemsList({
|
||||
data={entityData as unknown as TimelineSubmissionData}
|
||||
actionType={actionType}
|
||||
/>
|
||||
<div className="mt-6 pt-6 border-t">
|
||||
<div className="text-xs font-semibold text-muted-foreground uppercase tracking-wide mb-3">
|
||||
All Fields (Detailed View)
|
||||
</div>
|
||||
<DetailedViewCollapsible
|
||||
isCollapsed={isCollapsed}
|
||||
onToggle={toggle}
|
||||
fieldCount={countFields(entityData)}
|
||||
staggerIndex={index}
|
||||
>
|
||||
<SubmissionChangesDisplay
|
||||
item={item}
|
||||
view="detailed"
|
||||
showImages={showImages}
|
||||
submissionId={submissionId}
|
||||
/>
|
||||
</div>
|
||||
</DetailedViewCollapsible>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -320,9 +339,9 @@ export const SubmissionItemsList = memo(function SubmissionItemsList({
|
||||
)}
|
||||
|
||||
{/* Show regular submission items */}
|
||||
{items.map((item) => (
|
||||
{items.map((item, index) => (
|
||||
<div key={item.id} className={view === 'summary' ? 'border-l-2 border-primary/20 pl-3' : ''}>
|
||||
{renderItem(item)}
|
||||
{renderItem(item, index)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
|
||||
@@ -1,9 +1,30 @@
|
||||
import * as React from "react";
|
||||
import * as CollapsiblePrimitive from "@radix-ui/react-collapsible";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const Collapsible = CollapsiblePrimitive.Root;
|
||||
|
||||
const CollapsibleTrigger = CollapsiblePrimitive.CollapsibleTrigger;
|
||||
|
||||
const CollapsibleContent = CollapsiblePrimitive.CollapsibleContent;
|
||||
const CollapsibleContent = React.forwardRef<
|
||||
React.ElementRef<typeof CollapsiblePrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof CollapsiblePrimitive.Content>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<CollapsiblePrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"overflow-hidden transition-all duration-300 ease-out",
|
||||
"data-[state=closed]:animate-accordion-up",
|
||||
"data-[state=open]:animate-accordion-down",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<div className="animate-fade-in">
|
||||
{children}
|
||||
</div>
|
||||
</CollapsiblePrimitive.Content>
|
||||
));
|
||||
CollapsibleContent.displayName = "CollapsibleContent";
|
||||
|
||||
export { Collapsible, CollapsibleTrigger, CollapsibleContent };
|
||||
|
||||
@@ -12,7 +12,7 @@ import { useState, useCallback, useEffect } from 'react';
|
||||
import { useDebounce } from '@/hooks/useDebounce';
|
||||
import { logger } from '@/lib/logger';
|
||||
import { MODERATION_CONSTANTS } from '@/lib/moderation/constants';
|
||||
import type { EntityFilter, StatusFilter, QueueTab, SortConfig, SortField } from '@/types/moderation';
|
||||
import type { EntityFilter, StatusFilter, QueueTab, SortConfig, SortField, ApprovalDateRangeFilter } from '@/types/moderation';
|
||||
import * as storage from '@/lib/localStorage';
|
||||
|
||||
export interface ModerationFiltersConfig {
|
||||
@@ -36,6 +36,9 @@ export interface ModerationFiltersConfig {
|
||||
|
||||
/** Initial sort configuration */
|
||||
initialSortConfig?: SortConfig;
|
||||
|
||||
/** Initial approval date range filter */
|
||||
initialApprovalDateRange?: ApprovalDateRangeFilter;
|
||||
}
|
||||
|
||||
export interface ModerationFilters {
|
||||
@@ -87,6 +90,15 @@ export interface ModerationFilters {
|
||||
/** Reset sort to default */
|
||||
resetSort: () => void;
|
||||
|
||||
/** Approval date range filter (immediate) */
|
||||
approvalDateRange: ApprovalDateRangeFilter;
|
||||
|
||||
/** Debounced approval date range (use this for queries) */
|
||||
debouncedApprovalDateRange: ApprovalDateRangeFilter;
|
||||
|
||||
/** Set approval date range */
|
||||
setApprovalDateRange: (range: ApprovalDateRangeFilter) => void;
|
||||
|
||||
/** Reset pagination to page 1 (callback) */
|
||||
onFilterChange?: () => void;
|
||||
}
|
||||
@@ -121,6 +133,7 @@ export function useModerationFilters(
|
||||
persist = true,
|
||||
storageKey = 'moderationQueue_filters',
|
||||
initialSortConfig = { field: 'created_at', direction: 'asc' },
|
||||
initialApprovalDateRange = { from: null, to: null },
|
||||
onFilterChange,
|
||||
} = config;
|
||||
|
||||
@@ -174,6 +187,9 @@ export function useModerationFilters(
|
||||
|
||||
// Sort state
|
||||
const [sortConfig, setSortConfigState] = useState<SortConfig>(loadPersistedSort);
|
||||
|
||||
// Approval date range state
|
||||
const [approvalDateRange, setApprovalDateRangeState] = useState<ApprovalDateRangeFilter>(initialApprovalDateRange);
|
||||
|
||||
// Debounced filters for API calls
|
||||
const debouncedEntityFilter = useDebounce(entityFilter, debounceDelay);
|
||||
@@ -181,6 +197,9 @@ export function useModerationFilters(
|
||||
|
||||
// Debounced sort (0ms for immediate feedback)
|
||||
const debouncedSortConfig = useDebounce(sortConfig, 0);
|
||||
|
||||
// Debounced approval date range
|
||||
const debouncedApprovalDateRange = useDebounce(approvalDateRange, debounceDelay);
|
||||
|
||||
// Persist filters to localStorage
|
||||
useEffect(() => {
|
||||
@@ -246,6 +265,13 @@ export function useModerationFilters(
|
||||
const resetSort = useCallback(() => {
|
||||
setSortConfigState(initialSortConfig);
|
||||
}, [initialSortConfig]);
|
||||
|
||||
// Set approval date range with logging and pagination reset
|
||||
const setApprovalDateRange = useCallback((range: ApprovalDateRangeFilter) => {
|
||||
logger.log('🔍 Approval date range changed:', range);
|
||||
setApprovalDateRangeState(range);
|
||||
onFilterChange?.();
|
||||
}, [onFilterChange]);
|
||||
|
||||
// Clear all filters
|
||||
const clearFilters = useCallback(() => {
|
||||
@@ -254,7 +280,8 @@ export function useModerationFilters(
|
||||
setStatusFilterState(initialStatusFilter);
|
||||
setActiveTabState(initialTab);
|
||||
setSortConfigState(initialSortConfig);
|
||||
}, [initialEntityFilter, initialStatusFilter, initialTab, initialSortConfig]);
|
||||
setApprovalDateRangeState(initialApprovalDateRange);
|
||||
}, [initialEntityFilter, initialStatusFilter, initialTab, initialSortConfig, initialApprovalDateRange]);
|
||||
|
||||
// Check if non-default filters are active
|
||||
const hasActiveFilters =
|
||||
@@ -262,7 +289,9 @@ export function useModerationFilters(
|
||||
statusFilter !== initialStatusFilter ||
|
||||
activeTab !== initialTab ||
|
||||
sortConfig.field !== initialSortConfig.field ||
|
||||
sortConfig.direction !== initialSortConfig.direction;
|
||||
sortConfig.direction !== initialSortConfig.direction ||
|
||||
approvalDateRange.from !== null ||
|
||||
approvalDateRange.to !== null;
|
||||
|
||||
// Return without useMemo wrapper (OPTIMIZED)
|
||||
return {
|
||||
@@ -282,6 +311,9 @@ export function useModerationFilters(
|
||||
sortBy,
|
||||
toggleSortDirection,
|
||||
resetSort,
|
||||
approvalDateRange,
|
||||
debouncedApprovalDateRange,
|
||||
setApprovalDateRange,
|
||||
onFilterChange,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -174,6 +174,7 @@ export function useModerationQueueManager(config: ModerationQueueManagerConfig):
|
||||
currentPage: pagination.currentPage,
|
||||
pageSize: pagination.pageSize,
|
||||
sortConfig: filters.debouncedSortConfig,
|
||||
approvalDateRange: filters.debouncedApprovalDateRange,
|
||||
enabled: !!user,
|
||||
});
|
||||
|
||||
|
||||
@@ -98,6 +98,12 @@ export interface UseQueueQueryConfig {
|
||||
direction: SortDirection;
|
||||
};
|
||||
|
||||
/** Approval date range filter */
|
||||
approvalDateRange?: {
|
||||
from: Date | null;
|
||||
to: Date | null;
|
||||
};
|
||||
|
||||
/** Whether query is enabled (defaults to true) */
|
||||
enabled?: boolean;
|
||||
}
|
||||
@@ -145,6 +151,7 @@ export function useQueueQuery(config: UseQueueQueryConfig): UseQueueQueryReturn
|
||||
currentPage: config.currentPage,
|
||||
pageSize: config.pageSize,
|
||||
sortConfig: config.sortConfig,
|
||||
approvalDateRange: config.approvalDateRange,
|
||||
};
|
||||
|
||||
// Create stable query key (TanStack Query uses this for caching/deduplication)
|
||||
@@ -161,6 +168,8 @@ export function useQueueQuery(config: UseQueueQueryConfig): UseQueueQueryReturn
|
||||
config.pageSize,
|
||||
config.sortConfig.field,
|
||||
config.sortConfig.direction,
|
||||
config.approvalDateRange?.from?.toISOString(),
|
||||
config.approvalDateRange?.to?.toISOString(),
|
||||
];
|
||||
|
||||
// Execute query
|
||||
|
||||
129
src/hooks/useDetailedViewState.ts
Normal file
129
src/hooks/useDetailedViewState.ts
Normal file
@@ -0,0 +1,129 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { logger } from '@/lib/logger';
|
||||
import { useAuth } from '@/hooks/useAuth';
|
||||
import { supabase } from '@/lib/supabaseClient';
|
||||
import { handleNonCriticalError } from '@/lib/errorHandler';
|
||||
import type { Json } from '@/integrations/supabase/types';
|
||||
|
||||
const STORAGE_KEY = 'detailed-view-collapsed';
|
||||
|
||||
interface ModerationPreferences {
|
||||
detailed_view_collapsed: boolean;
|
||||
}
|
||||
|
||||
interface UseDetailedViewStateReturn {
|
||||
isCollapsed: boolean;
|
||||
toggle: () => void;
|
||||
setCollapsed: (value: boolean) => void;
|
||||
loading: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to manage detailed view collapsed/expanded state
|
||||
* Persists to database for authenticated users, localStorage for guests
|
||||
* Defaults to collapsed to reduce visual clutter
|
||||
*/
|
||||
export function useDetailedViewState(): UseDetailedViewStateReturn {
|
||||
const { user } = useAuth();
|
||||
const [isCollapsed, setIsCollapsed] = useState<boolean>(true);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
// Load preferences on mount
|
||||
useEffect(() => {
|
||||
loadPreferences();
|
||||
}, [user]);
|
||||
|
||||
const loadPreferences = async () => {
|
||||
try {
|
||||
if (user) {
|
||||
// Load from database for authenticated users
|
||||
const { data, error } = await supabase
|
||||
.from('user_preferences')
|
||||
.select('moderation_preferences')
|
||||
.eq('user_id', user.id)
|
||||
.maybeSingle();
|
||||
|
||||
if (error && error.code !== 'PGRST116') {
|
||||
handleNonCriticalError(error, {
|
||||
action: 'Load moderation preferences',
|
||||
userId: user.id,
|
||||
});
|
||||
}
|
||||
|
||||
// Type assertion needed until Supabase regenerates types after migration
|
||||
const preferences = (data as any)?.moderation_preferences;
|
||||
if (preferences) {
|
||||
const prefs = preferences as ModerationPreferences;
|
||||
setIsCollapsed(prefs.detailed_view_collapsed ?? true);
|
||||
}
|
||||
} else {
|
||||
// Load from localStorage for guests
|
||||
try {
|
||||
const stored = localStorage.getItem(STORAGE_KEY);
|
||||
setIsCollapsed(stored ? JSON.parse(stored) : true);
|
||||
} catch (error) {
|
||||
logger.warn('Error reading detailed view state from localStorage', { error });
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.warn('Error loading detailed view preferences', { error });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const savePreferences = async (collapsed: boolean) => {
|
||||
try {
|
||||
if (user) {
|
||||
// Save to database for authenticated users
|
||||
const moderationPrefs: ModerationPreferences = {
|
||||
detailed_view_collapsed: collapsed,
|
||||
};
|
||||
|
||||
const { error } = await supabase
|
||||
.from('user_preferences')
|
||||
.upsert({
|
||||
user_id: user.id,
|
||||
moderation_preferences: moderationPrefs as unknown as Json,
|
||||
updated_at: new Date().toISOString(),
|
||||
}, {
|
||||
onConflict: 'user_id',
|
||||
});
|
||||
|
||||
if (error) {
|
||||
handleNonCriticalError(error, {
|
||||
action: 'Save moderation preferences',
|
||||
userId: user.id,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// Save to localStorage for guests
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(collapsed));
|
||||
} catch (error) {
|
||||
logger.warn('Error saving detailed view state to localStorage', { error });
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.warn('Error saving detailed view preferences', { error });
|
||||
}
|
||||
};
|
||||
|
||||
const toggle = () => {
|
||||
const newValue = !isCollapsed;
|
||||
setIsCollapsed(newValue);
|
||||
savePreferences(newValue);
|
||||
};
|
||||
|
||||
const setCollapsed = (value: boolean) => {
|
||||
setIsCollapsed(value);
|
||||
savePreferences(value);
|
||||
};
|
||||
|
||||
return {
|
||||
isCollapsed,
|
||||
toggle,
|
||||
setCollapsed,
|
||||
loading,
|
||||
};
|
||||
}
|
||||
@@ -1872,6 +1872,13 @@ export type Database = {
|
||||
item_id?: string
|
||||
}
|
||||
Relationships: [
|
||||
{
|
||||
foreignKeyName: "item_edit_history_item_id_fkey"
|
||||
columns: ["item_id"]
|
||||
isOneToOne: false
|
||||
referencedRelation: "approval_history_detailed"
|
||||
referencedColumns: ["item_id"]
|
||||
},
|
||||
{
|
||||
foreignKeyName: "item_edit_history_item_id_fkey"
|
||||
columns: ["item_id"]
|
||||
@@ -5682,6 +5689,13 @@ export type Database = {
|
||||
submission_item_id?: string
|
||||
}
|
||||
Relationships: [
|
||||
{
|
||||
foreignKeyName: "submission_item_temp_refs_submission_item_id_fkey"
|
||||
columns: ["submission_item_id"]
|
||||
isOneToOne: false
|
||||
referencedRelation: "approval_history_detailed"
|
||||
referencedColumns: ["item_id"]
|
||||
},
|
||||
{
|
||||
foreignKeyName: "submission_item_temp_refs_submission_item_id_fkey"
|
||||
columns: ["submission_item_id"]
|
||||
@@ -5694,6 +5708,7 @@ export type Database = {
|
||||
submission_items: {
|
||||
Row: {
|
||||
action_type: string | null
|
||||
approved_at: string | null
|
||||
approved_entity_id: string | null
|
||||
company_submission_id: string | null
|
||||
created_at: string
|
||||
@@ -5714,6 +5729,7 @@ export type Database = {
|
||||
}
|
||||
Insert: {
|
||||
action_type?: string | null
|
||||
approved_at?: string | null
|
||||
approved_entity_id?: string | null
|
||||
company_submission_id?: string | null
|
||||
created_at?: string
|
||||
@@ -5734,6 +5750,7 @@ export type Database = {
|
||||
}
|
||||
Update: {
|
||||
action_type?: string | null
|
||||
approved_at?: string | null
|
||||
approved_entity_id?: string | null
|
||||
company_submission_id?: string | null
|
||||
created_at?: string
|
||||
@@ -5760,6 +5777,13 @@ export type Database = {
|
||||
referencedRelation: "company_submissions"
|
||||
referencedColumns: ["id"]
|
||||
},
|
||||
{
|
||||
foreignKeyName: "submission_items_depends_on_fkey"
|
||||
columns: ["depends_on"]
|
||||
isOneToOne: false
|
||||
referencedRelation: "approval_history_detailed"
|
||||
referencedColumns: ["item_id"]
|
||||
},
|
||||
{
|
||||
foreignKeyName: "submission_items_depends_on_fkey"
|
||||
columns: ["depends_on"]
|
||||
@@ -5931,6 +5955,13 @@ export type Database = {
|
||||
test_session_id?: string | null
|
||||
}
|
||||
Relationships: [
|
||||
{
|
||||
foreignKeyName: "test_data_registry_submission_item_id_fkey"
|
||||
columns: ["submission_item_id"]
|
||||
isOneToOne: false
|
||||
referencedRelation: "approval_history_detailed"
|
||||
referencedColumns: ["item_id"]
|
||||
},
|
||||
{
|
||||
foreignKeyName: "test_data_registry_submission_item_id_fkey"
|
||||
columns: ["submission_item_id"]
|
||||
@@ -6306,6 +6337,76 @@ export type Database = {
|
||||
}
|
||||
Relationships: []
|
||||
}
|
||||
approval_history_detailed: {
|
||||
Row: {
|
||||
action_type: string | null
|
||||
approval_time_seconds: number | null
|
||||
approved_at: string | null
|
||||
approved_entity_id: string | null
|
||||
approver_avatar_url: string | null
|
||||
approver_display_name: string | null
|
||||
approver_id: string | null
|
||||
approver_username: string | null
|
||||
created_at: string | null
|
||||
entity_name: string | null
|
||||
entity_slug: string | null
|
||||
item_id: string | null
|
||||
item_type: string | null
|
||||
status: string | null
|
||||
submission_id: string | null
|
||||
submission_type: string | null
|
||||
submitted_at: string | null
|
||||
submitter_avatar_url: string | null
|
||||
submitter_display_name: string | null
|
||||
submitter_id: string | null
|
||||
submitter_username: string | null
|
||||
updated_at: string | null
|
||||
}
|
||||
Relationships: [
|
||||
{
|
||||
foreignKeyName: "content_submissions_reviewer_id_fkey"
|
||||
columns: ["approver_id"]
|
||||
isOneToOne: false
|
||||
referencedRelation: "filtered_profiles"
|
||||
referencedColumns: ["user_id"]
|
||||
},
|
||||
{
|
||||
foreignKeyName: "content_submissions_reviewer_id_fkey"
|
||||
columns: ["approver_id"]
|
||||
isOneToOne: false
|
||||
referencedRelation: "profiles"
|
||||
referencedColumns: ["user_id"]
|
||||
},
|
||||
{
|
||||
foreignKeyName: "content_submissions_user_id_fkey"
|
||||
columns: ["submitter_id"]
|
||||
isOneToOne: false
|
||||
referencedRelation: "filtered_profiles"
|
||||
referencedColumns: ["user_id"]
|
||||
},
|
||||
{
|
||||
foreignKeyName: "content_submissions_user_id_fkey"
|
||||
columns: ["submitter_id"]
|
||||
isOneToOne: false
|
||||
referencedRelation: "profiles"
|
||||
referencedColumns: ["user_id"]
|
||||
},
|
||||
{
|
||||
foreignKeyName: "submission_items_submission_id_fkey"
|
||||
columns: ["submission_id"]
|
||||
isOneToOne: false
|
||||
referencedRelation: "content_submissions"
|
||||
referencedColumns: ["id"]
|
||||
},
|
||||
{
|
||||
foreignKeyName: "submission_items_submission_id_fkey"
|
||||
columns: ["submission_id"]
|
||||
isOneToOne: false
|
||||
referencedRelation: "moderation_queue_with_entities"
|
||||
referencedColumns: ["id"]
|
||||
},
|
||||
]
|
||||
}
|
||||
data_retention_stats: {
|
||||
Row: {
|
||||
last_30_days: number | null
|
||||
@@ -6831,6 +6932,40 @@ export type Database = {
|
||||
Returns: string
|
||||
}
|
||||
generate_ticket_number: { Args: never; Returns: string }
|
||||
get_approval_history: {
|
||||
Args: {
|
||||
p_approver_id?: string
|
||||
p_from_date?: string
|
||||
p_item_type?: string
|
||||
p_limit?: number
|
||||
p_offset?: number
|
||||
p_to_date?: string
|
||||
}
|
||||
Returns: {
|
||||
action_type: string
|
||||
approval_time_seconds: number
|
||||
approved_at: string
|
||||
approved_entity_id: string
|
||||
approver_avatar_url: string
|
||||
approver_display_name: string
|
||||
approver_id: string
|
||||
approver_username: string
|
||||
created_at: string
|
||||
entity_name: string
|
||||
entity_slug: string
|
||||
item_id: string
|
||||
item_type: string
|
||||
status: string
|
||||
submission_id: string
|
||||
submission_type: string
|
||||
submitted_at: string
|
||||
submitter_avatar_url: string
|
||||
submitter_display_name: string
|
||||
submitter_id: string
|
||||
submitter_username: string
|
||||
updated_at: string
|
||||
}[]
|
||||
}
|
||||
get_auth0_sub_from_jwt: { Args: never; Returns: string }
|
||||
get_contributor_leaderboard: {
|
||||
Args: { limit_count?: number; time_period?: string }
|
||||
@@ -7066,13 +7201,13 @@ export type Database = {
|
||||
monitor_slow_approvals: { Args: never; Returns: undefined }
|
||||
process_approval_transaction: {
|
||||
Args: {
|
||||
p_approval_mode?: string
|
||||
p_idempotency_key?: string
|
||||
p_item_ids: string[]
|
||||
p_moderator_id: string
|
||||
p_parent_span_id?: string
|
||||
p_request_id?: string
|
||||
p_submission_id: string
|
||||
p_submitter_id: string
|
||||
p_trace_id?: string
|
||||
}
|
||||
Returns: Json
|
||||
}
|
||||
@@ -7099,6 +7234,7 @@ export type Database = {
|
||||
}
|
||||
Returns: Json
|
||||
}
|
||||
refresh_approval_history: { Args: never; Returns: undefined }
|
||||
release_expired_locks: { Args: never; Returns: number }
|
||||
release_submission_lock: {
|
||||
Args: { moderator_id: string; submission_id: string }
|
||||
|
||||
@@ -26,6 +26,7 @@ export interface QueryConfig {
|
||||
currentPage: number;
|
||||
pageSize: number;
|
||||
sortConfig?: SortConfig;
|
||||
approvalDateRange?: { from: Date | null; to: Date | null };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -53,7 +54,7 @@ export function buildSubmissionQuery(
|
||||
config: QueryConfig,
|
||||
skipModeratorFilter = false
|
||||
) {
|
||||
const { entityFilter, statusFilter, tab, userId, isAdmin, isSuperuser } = config;
|
||||
const { entityFilter, statusFilter, tab, userId, isAdmin, isSuperuser, approvalDateRange } = config;
|
||||
|
||||
// Use optimized view with pre-joined profiles and entity data
|
||||
let query = supabase
|
||||
@@ -103,6 +104,20 @@ export function buildSubmissionQuery(
|
||||
}
|
||||
// 'all' and 'reviews' filters don't add any conditions
|
||||
|
||||
// Apply approval date range filter (only works on archive tab with approved items)
|
||||
if (approvalDateRange && tab === 'archive') {
|
||||
if (approvalDateRange.from) {
|
||||
// Filter by checking if submission has at least one item approved on/after this date
|
||||
query = query.gte('first_item_approved_at', approvalDateRange.from.toISOString());
|
||||
}
|
||||
if (approvalDateRange.to) {
|
||||
// Add one day and use < to include the entire "to" day
|
||||
const nextDay = new Date(approvalDateRange.to);
|
||||
nextDay.setDate(nextDay.getDate() + 1);
|
||||
query = query.lt('last_item_approved_at', nextDay.toISOString());
|
||||
}
|
||||
}
|
||||
|
||||
// CRM-style claim filtering: moderators only see unclaimed OR self-assigned submissions
|
||||
// Admins see all submissions
|
||||
// Note: For non-admin users, moderator filtering is handled by multi-query approach in fetchSubmissions
|
||||
|
||||
@@ -7,6 +7,7 @@ import { twMerge } from "tailwind-merge";
|
||||
*
|
||||
* @param inputs - Class values to combine (strings, objects, arrays)
|
||||
* @returns Merged class string with Tailwind conflicts resolved
|
||||
* @example cn('px-2 py-1', 'px-4') // Returns 'py-1 px-4'
|
||||
*/
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
|
||||
136
src/pages/admin/ApprovalHistory.tsx
Normal file
136
src/pages/admin/ApprovalHistory.tsx
Normal file
@@ -0,0 +1,136 @@
|
||||
/**
|
||||
* Approval History Page
|
||||
*
|
||||
* Full-page view for compliance reporting with advanced filters,
|
||||
* date range selection, and export functionality.
|
||||
*/
|
||||
|
||||
import { useState } from 'react';
|
||||
import { ItemApprovalHistory } from '@/components/moderation/ItemApprovalHistory';
|
||||
import { FilterDateRangePicker } from '@/components/filters/FilterDateRangePicker';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { X, FileCheck } from 'lucide-react';
|
||||
import { useUserRole } from '@/hooks/useUserRole';
|
||||
import { Navigate } from 'react-router-dom';
|
||||
import type { EntityType } from '@/types/submissions';
|
||||
|
||||
export default function ApprovalHistory() {
|
||||
const { isModerator, loading: rolesLoading } = useUserRole();
|
||||
const [fromDate, setFromDate] = useState<Date | null>(null);
|
||||
const [toDate, setToDate] = useState<Date | null>(null);
|
||||
const [itemType, setItemType] = useState<EntityType | 'all'>('all');
|
||||
const [limit, setLimit] = useState<number>(100);
|
||||
|
||||
// Access control: moderators only
|
||||
if (rolesLoading) {
|
||||
return (
|
||||
<div className="container mx-auto p-6">
|
||||
<div className="text-center">Loading...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!isModerator()) {
|
||||
return <Navigate to="/" replace />;
|
||||
}
|
||||
|
||||
const hasFilters = fromDate || toDate || itemType !== 'all' || limit !== 100;
|
||||
|
||||
const clearFilters = () => {
|
||||
setFromDate(null);
|
||||
setToDate(null);
|
||||
setItemType('all');
|
||||
setLimit(100);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="container mx-auto p-6 max-w-7xl">
|
||||
{/* Header */}
|
||||
<div className="mb-6">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<FileCheck className="w-8 h-8 text-primary" />
|
||||
<h1 className="text-3xl font-bold">Approval History</h1>
|
||||
</div>
|
||||
<p className="text-muted-foreground">
|
||||
Complete audit trail of all approved items with exact timestamps for compliance reporting
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<Card className="mb-6">
|
||||
<CardContent className="pt-6">
|
||||
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-4">
|
||||
{/* Date Range Filter */}
|
||||
<div className="lg:col-span-2">
|
||||
<FilterDateRangePicker
|
||||
label="Approval Date Range"
|
||||
fromDate={fromDate}
|
||||
toDate={toDate}
|
||||
onFromChange={(date) => setFromDate(date || null)}
|
||||
onToChange={(date) => setToDate(date || null)}
|
||||
fromPlaceholder="Start Date"
|
||||
toPlaceholder="End Date"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Item Type Filter */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="item-type">Item Type</Label>
|
||||
<Select value={itemType} onValueChange={(val) => setItemType(val as EntityType | 'all')}>
|
||||
<SelectTrigger id="item-type">
|
||||
<SelectValue placeholder="All Types" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All Types</SelectItem>
|
||||
<SelectItem value="park">Parks</SelectItem>
|
||||
<SelectItem value="ride">Rides</SelectItem>
|
||||
<SelectItem value="manufacturer">Manufacturers</SelectItem>
|
||||
<SelectItem value="designer">Designers</SelectItem>
|
||||
<SelectItem value="operator">Operators</SelectItem>
|
||||
<SelectItem value="ride_model">Ride Models</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Results Limit */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="limit">Results Limit</Label>
|
||||
<Select value={limit.toString()} onValueChange={(val) => setLimit(parseInt(val))}>
|
||||
<SelectTrigger id="limit">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="50">50</SelectItem>
|
||||
<SelectItem value="100">100</SelectItem>
|
||||
<SelectItem value="250">250</SelectItem>
|
||||
<SelectItem value="500">500</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Clear Filters */}
|
||||
{hasFilters && (
|
||||
<div className="mt-4 flex justify-end">
|
||||
<Button variant="outline" size="sm" onClick={clearFilters}>
|
||||
<X className="w-4 h-4 mr-2" />
|
||||
Clear Filters
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* History Table */}
|
||||
<ItemApprovalHistory
|
||||
dateRange={fromDate && toDate ? { from: fromDate, to: toDate } : undefined}
|
||||
itemType={itemType === 'all' ? undefined : itemType}
|
||||
limit={limit}
|
||||
embedded={false}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -313,6 +313,14 @@ export interface SortConfig {
|
||||
direction: SortDirection;
|
||||
}
|
||||
|
||||
/**
|
||||
* Approval date range filter for moderation queue
|
||||
*/
|
||||
export interface ApprovalDateRangeFilter {
|
||||
from: Date | null;
|
||||
to: Date | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Loading states for the moderation queue
|
||||
*/
|
||||
|
||||
@@ -43,6 +43,7 @@ export interface SubmissionItemData {
|
||||
rejection_reason: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
approved_at: string | null;
|
||||
}
|
||||
|
||||
export interface EntityPhotoGalleryProps {
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { serve } from 'https://deno.land/std@0.190.0/http/server.ts';
|
||||
import { createEdgeFunction } from '../_shared/edgeFunctionWrapper.ts';
|
||||
import { corsHeadersWithTracing } from '../_shared/cors.ts';
|
||||
import {
|
||||
addSpanEvent,
|
||||
setSpanAttributes,
|
||||
@@ -205,8 +207,8 @@ const handler = async (req: Request, context: { supabase: any; user: any; span:
|
||||
p_moderator_id: user.id,
|
||||
p_submitter_id: submission.user_id,
|
||||
p_request_id: requestId,
|
||||
p_trace_id: rootSpan.traceId,
|
||||
p_parent_span_id: rpcSpan.spanId
|
||||
p_approval_mode: 'selective',
|
||||
p_idempotency_key: idempotencyKey
|
||||
}
|
||||
);
|
||||
|
||||
@@ -289,13 +291,11 @@ const handler = async (req: Request, context: { supabase: any; user: any; span:
|
||||
};
|
||||
|
||||
// Create edge function with automatic error handling, CORS, auth, and logging
|
||||
createEdgeFunction(
|
||||
serve(createEdgeFunction(
|
||||
{
|
||||
name: 'process-selective-approval',
|
||||
requireAuth: true,
|
||||
corsEnabled: true,
|
||||
enableTracing: true,
|
||||
rateLimitTier: 'moderate'
|
||||
corsHeaders: corsHeadersWithTracing,
|
||||
},
|
||||
handler
|
||||
);
|
||||
));
|
||||
|
||||
@@ -0,0 +1,326 @@
|
||||
-- ============================================================================
|
||||
-- Fix: Remove non-existent approved_at column from submission_items update
|
||||
-- ============================================================================
|
||||
-- The submission_items table does not have an approved_at column.
|
||||
-- This migration removes that reference from the process_approval_transaction function.
|
||||
-- ============================================================================
|
||||
|
||||
DROP FUNCTION IF EXISTS process_approval_transaction(UUID, UUID[], UUID, UUID, TEXT, TEXT, TEXT);
|
||||
|
||||
CREATE OR REPLACE FUNCTION process_approval_transaction(
|
||||
p_submission_id UUID,
|
||||
p_item_ids UUID[],
|
||||
p_moderator_id UUID,
|
||||
p_submitter_id UUID,
|
||||
p_request_id TEXT DEFAULT NULL,
|
||||
p_trace_id TEXT DEFAULT NULL,
|
||||
p_parent_span_id TEXT DEFAULT NULL
|
||||
)
|
||||
RETURNS JSONB
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
SET search_path = public
|
||||
AS $$
|
||||
DECLARE
|
||||
v_start_time TIMESTAMPTZ;
|
||||
v_result JSONB;
|
||||
v_item RECORD;
|
||||
v_item_data JSONB;
|
||||
v_resolved_refs JSONB;
|
||||
v_entity_id UUID;
|
||||
v_approval_results JSONB[] := ARRAY[]::JSONB[];
|
||||
v_final_status TEXT;
|
||||
v_all_approved BOOLEAN := TRUE;
|
||||
v_some_approved BOOLEAN := FALSE;
|
||||
v_items_processed INTEGER := 0;
|
||||
v_span_id TEXT;
|
||||
BEGIN
|
||||
v_start_time := clock_timestamp();
|
||||
v_span_id := gen_random_uuid()::text;
|
||||
|
||||
-- Log span start with trace context
|
||||
IF p_trace_id IS NOT NULL THEN
|
||||
RAISE NOTICE 'SPAN: {"spanId": "%", "traceId": "%", "parentSpanId": "%", "name": "process_approval_transaction_rpc", "kind": "INTERNAL", "startTime": %, "attributes": {"submission.id": "%", "item_count": %}}',
|
||||
v_span_id,
|
||||
p_trace_id,
|
||||
p_parent_span_id,
|
||||
EXTRACT(EPOCH FROM v_start_time) * 1000,
|
||||
p_submission_id,
|
||||
array_length(p_item_ids, 1);
|
||||
END IF;
|
||||
|
||||
RAISE NOTICE '[%] Starting atomic approval transaction for submission %',
|
||||
COALESCE(p_request_id, 'NO_REQUEST_ID'),
|
||||
p_submission_id;
|
||||
|
||||
-- ========================================================================
|
||||
-- STEP 1: Set session variables (transaction-scoped with is_local=true)
|
||||
-- ========================================================================
|
||||
PERFORM set_config('app.current_user_id', p_submitter_id::text, true);
|
||||
PERFORM set_config('app.submission_id', p_submission_id::text, true);
|
||||
PERFORM set_config('app.moderator_id', p_moderator_id::text, true);
|
||||
|
||||
-- ========================================================================
|
||||
-- STEP 2: Validate submission ownership and lock status
|
||||
-- ========================================================================
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM content_submissions
|
||||
WHERE id = p_submission_id
|
||||
AND (assigned_to = p_moderator_id OR assigned_to IS NULL)
|
||||
AND status IN ('pending', 'partially_approved')
|
||||
) THEN
|
||||
RAISE EXCEPTION 'Submission not found, locked by another moderator, or already processed'
|
||||
USING ERRCODE = '42501';
|
||||
END IF;
|
||||
|
||||
-- ========================================================================
|
||||
-- STEP 3: Process each item sequentially within this transaction
|
||||
-- ========================================================================
|
||||
FOR v_item IN
|
||||
SELECT
|
||||
si.*,
|
||||
ps.name as park_name,
|
||||
ps.slug as park_slug,
|
||||
ps.description as park_description,
|
||||
ps.park_type,
|
||||
ps.status as park_status,
|
||||
ps.location_id,
|
||||
ps.operator_id,
|
||||
ps.property_owner_id,
|
||||
ps.opening_date as park_opening_date,
|
||||
ps.closing_date as park_closing_date,
|
||||
ps.opening_date_precision as park_opening_date_precision,
|
||||
ps.closing_date_precision as park_closing_date_precision,
|
||||
ps.website_url as park_website_url,
|
||||
ps.phone as park_phone,
|
||||
ps.email as park_email,
|
||||
ps.banner_image_url as park_banner_image_url,
|
||||
ps.banner_image_id as park_banner_image_id,
|
||||
ps.card_image_url as park_card_image_url,
|
||||
ps.card_image_id as park_card_image_id,
|
||||
rs.name as ride_name,
|
||||
rs.slug as ride_slug,
|
||||
rs.park_id as ride_park_id,
|
||||
rs.category as ride_category,
|
||||
rs.status as ride_status,
|
||||
rs.manufacturer_id,
|
||||
rs.ride_model_id,
|
||||
rs.opening_date as ride_opening_date,
|
||||
rs.closing_date as ride_closing_date,
|
||||
rs.opening_date_precision as ride_opening_date_precision,
|
||||
rs.closing_date_precision as ride_closing_date_precision,
|
||||
rs.description as ride_description,
|
||||
rs.banner_image_url as ride_banner_image_url,
|
||||
rs.banner_image_id as ride_banner_image_id,
|
||||
rs.card_image_url as ride_card_image_url,
|
||||
rs.card_image_id as ride_card_image_id,
|
||||
cs.name as company_name,
|
||||
cs.slug as company_slug,
|
||||
cs.description as company_description,
|
||||
cs.website_url as company_website_url,
|
||||
cs.founded_year,
|
||||
cs.banner_image_url as company_banner_image_url,
|
||||
cs.banner_image_id as company_banner_image_id,
|
||||
cs.card_image_url as company_card_image_url,
|
||||
cs.card_image_id as company_card_image_id,
|
||||
rms.name as ride_model_name,
|
||||
rms.slug as ride_model_slug,
|
||||
rms.manufacturer_id as ride_model_manufacturer_id,
|
||||
rms.category as ride_model_category,
|
||||
rms.description as ride_model_description,
|
||||
rms.banner_image_url as ride_model_banner_image_url,
|
||||
rms.banner_image_id as ride_model_banner_image_id,
|
||||
rms.card_image_url as ride_model_card_image_url,
|
||||
rms.card_image_id as ride_model_card_image_id,
|
||||
phs.entity_id as photo_entity_id,
|
||||
phs.entity_type as photo_entity_type,
|
||||
phs.title as photo_title
|
||||
FROM submission_items si
|
||||
LEFT JOIN park_submissions ps ON si.park_submission_id = ps.id
|
||||
LEFT JOIN ride_submissions rs ON si.ride_submission_id = rs.id
|
||||
LEFT JOIN company_submissions cs ON si.company_submission_id = cs.id
|
||||
LEFT JOIN ride_model_submissions rms ON si.ride_model_submission_id = rms.id
|
||||
LEFT JOIN photo_submissions phs ON si.photo_submission_id = phs.id
|
||||
WHERE si.id = ANY(p_item_ids)
|
||||
ORDER BY si.order_index, si.created_at
|
||||
LOOP
|
||||
BEGIN
|
||||
v_items_processed := v_items_processed + 1;
|
||||
|
||||
-- Log item processing span event
|
||||
IF p_trace_id IS NOT NULL THEN
|
||||
RAISE NOTICE 'SPAN_EVENT: {"traceId": "%", "parentSpanId": "%", "name": "process_item", "timestamp": %, "attributes": {"item.id": "%", "item.type": "%", "item.action": "%"}}',
|
||||
p_trace_id,
|
||||
v_span_id,
|
||||
EXTRACT(EPOCH FROM clock_timestamp()) * 1000,
|
||||
v_item.id,
|
||||
v_item.item_type,
|
||||
v_item.action_type;
|
||||
END IF;
|
||||
|
||||
-- Build item data based on entity type
|
||||
IF v_item.item_type = 'park' THEN
|
||||
v_item_data := jsonb_build_object(
|
||||
'name', v_item.park_name,
|
||||
'slug', v_item.park_slug,
|
||||
'description', v_item.park_description,
|
||||
'park_type', v_item.park_type,
|
||||
'status', v_item.park_status,
|
||||
'location_id', v_item.location_id,
|
||||
'operator_id', v_item.operator_id,
|
||||
'property_owner_id', v_item.property_owner_id,
|
||||
'opening_date', v_item.park_opening_date,
|
||||
'closing_date', v_item.park_closing_date,
|
||||
'opening_date_precision', v_item.park_opening_date_precision,
|
||||
'closing_date_precision', v_item.park_closing_date_precision,
|
||||
'website_url', v_item.park_website_url,
|
||||
'phone', v_item.park_phone,
|
||||
'email', v_item.park_email,
|
||||
'banner_image_url', v_item.park_banner_image_url,
|
||||
'banner_image_id', v_item.park_banner_image_id,
|
||||
'card_image_url', v_item.park_card_image_url,
|
||||
'card_image_id', v_item.park_card_image_id
|
||||
);
|
||||
ELSIF v_item.item_type = 'ride' THEN
|
||||
v_item_data := jsonb_build_object(
|
||||
'name', v_item.ride_name,
|
||||
'slug', v_item.ride_slug,
|
||||
'park_id', v_item.ride_park_id,
|
||||
'category', v_item.ride_category,
|
||||
'status', v_item.ride_status,
|
||||
'manufacturer_id', v_item.manufacturer_id,
|
||||
'ride_model_id', v_item.ride_model_id,
|
||||
'opening_date', v_item.ride_opening_date,
|
||||
'closing_date', v_item.ride_closing_date,
|
||||
'opening_date_precision', v_item.ride_opening_date_precision,
|
||||
'closing_date_precision', v_item.ride_closing_date_precision,
|
||||
'description', v_item.ride_description,
|
||||
'banner_image_url', v_item.ride_banner_image_url,
|
||||
'banner_image_id', v_item.ride_banner_image_id,
|
||||
'card_image_url', v_item.ride_card_image_url,
|
||||
'card_image_id', v_item.ride_card_image_id
|
||||
);
|
||||
-- FIX: Support both granular company types AND consolidated 'company' type
|
||||
ELSIF v_item.item_type IN ('company', 'manufacturer', 'operator', 'property_owner', 'designer') THEN
|
||||
v_item_data := jsonb_build_object(
|
||||
'name', v_item.company_name,
|
||||
'slug', v_item.company_slug,
|
||||
'description', v_item.company_description,
|
||||
'website_url', v_item.company_website_url,
|
||||
'founded_year', v_item.founded_year,
|
||||
'banner_image_url', v_item.company_banner_image_url,
|
||||
'banner_image_id', v_item.company_banner_image_id,
|
||||
'card_image_url', v_item.company_card_image_url,
|
||||
'card_image_id', v_item.company_card_image_id
|
||||
);
|
||||
ELSIF v_item.item_type = 'ride_model' THEN
|
||||
v_item_data := jsonb_build_object(
|
||||
'name', v_item.ride_model_name,
|
||||
'slug', v_item.ride_model_slug,
|
||||
'manufacturer_id', v_item.ride_model_manufacturer_id,
|
||||
'category', v_item.ride_model_category,
|
||||
'description', v_item.ride_model_description,
|
||||
'banner_image_url', v_item.ride_model_banner_image_url,
|
||||
'banner_image_id', v_item.ride_model_banner_image_id,
|
||||
'card_image_url', v_item.ride_model_card_image_url,
|
||||
'card_image_id', v_item.ride_model_card_image_id
|
||||
);
|
||||
ELSIF v_item.item_type = 'photo' THEN
|
||||
v_item_data := jsonb_build_object(
|
||||
'entity_id', v_item.photo_entity_id,
|
||||
'entity_type', v_item.photo_entity_type,
|
||||
'title', v_item.photo_title
|
||||
);
|
||||
ELSE
|
||||
RAISE EXCEPTION 'Unknown item type: %', v_item.item_type;
|
||||
END IF;
|
||||
|
||||
-- Resolve temporary references
|
||||
v_resolved_refs := resolve_temp_references(v_item_data, p_submission_id);
|
||||
|
||||
-- Perform the action
|
||||
IF v_item.action_type = 'create' THEN
|
||||
v_entity_id := perform_create(v_item.item_type, v_resolved_refs, p_submitter_id, p_submission_id);
|
||||
ELSIF v_item.action_type = 'update' THEN
|
||||
IF v_item.entity_id IS NULL THEN
|
||||
RAISE EXCEPTION 'Update action requires entity_id';
|
||||
END IF;
|
||||
PERFORM perform_update(v_item.item_type, v_item.entity_id, v_resolved_refs, p_submitter_id, p_submission_id);
|
||||
v_entity_id := v_item.entity_id;
|
||||
ELSE
|
||||
RAISE EXCEPTION 'Unknown action type: %', v_item.action_type;
|
||||
END IF;
|
||||
|
||||
-- Update submission_item with approved entity (removed approved_at - column doesn't exist)
|
||||
UPDATE submission_items
|
||||
SET approved_entity_id = v_entity_id,
|
||||
status = 'approved',
|
||||
updated_at = now()
|
||||
WHERE id = v_item.id;
|
||||
|
||||
-- Track approval results
|
||||
v_approval_results := array_append(v_approval_results, jsonb_build_object(
|
||||
'item_id', v_item.id,
|
||||
'status', 'approved',
|
||||
'entity_id', v_entity_id
|
||||
));
|
||||
|
||||
v_some_approved := TRUE;
|
||||
|
||||
EXCEPTION
|
||||
WHEN OTHERS THEN
|
||||
-- Log the error
|
||||
RAISE WARNING 'Failed to process item %: % - %', v_item.id, SQLERRM, SQLSTATE;
|
||||
|
||||
-- Track failure
|
||||
v_approval_results := array_append(v_approval_results, jsonb_build_object(
|
||||
'item_id', v_item.id,
|
||||
'status', 'failed',
|
||||
'error', SQLERRM
|
||||
));
|
||||
|
||||
v_all_approved := FALSE;
|
||||
|
||||
-- Re-raise to rollback transaction
|
||||
RAISE;
|
||||
END;
|
||||
END LOOP;
|
||||
|
||||
-- ========================================================================
|
||||
-- STEP 4: Update submission status
|
||||
-- ========================================================================
|
||||
IF v_all_approved THEN
|
||||
v_final_status := 'approved';
|
||||
ELSIF v_some_approved THEN
|
||||
v_final_status := 'partially_approved';
|
||||
ELSE
|
||||
v_final_status := 'rejected';
|
||||
END IF;
|
||||
|
||||
UPDATE content_submissions
|
||||
SET status = v_final_status,
|
||||
resolved_at = CASE WHEN v_all_approved THEN now() ELSE NULL END,
|
||||
reviewer_id = p_moderator_id,
|
||||
reviewed_at = now()
|
||||
WHERE id = p_submission_id;
|
||||
|
||||
-- Log span end
|
||||
IF p_trace_id IS NOT NULL THEN
|
||||
RAISE NOTICE 'SPAN: {"spanId": "%", "traceId": "%", "name": "process_approval_transaction_rpc", "kind": "INTERNAL", "endTime": %, "attributes": {"items_processed": %, "final_status": "%"}}',
|
||||
v_span_id,
|
||||
p_trace_id,
|
||||
EXTRACT(EPOCH FROM clock_timestamp()) * 1000,
|
||||
v_items_processed,
|
||||
v_final_status;
|
||||
END IF;
|
||||
|
||||
-- Return result
|
||||
RETURN jsonb_build_object(
|
||||
'success', v_all_approved,
|
||||
'status', v_final_status,
|
||||
'items_processed', v_items_processed,
|
||||
'results', v_approval_results,
|
||||
'duration_ms', EXTRACT(EPOCH FROM (clock_timestamp() - v_start_time)) * 1000
|
||||
);
|
||||
END;
|
||||
$$;
|
||||
@@ -0,0 +1,271 @@
|
||||
-- Add approved_at column to submission_items table
|
||||
ALTER TABLE submission_items
|
||||
ADD COLUMN approved_at timestamp with time zone;
|
||||
|
||||
-- Add index for analytics queries (filtered index for performance)
|
||||
CREATE INDEX idx_submission_items_approved_at
|
||||
ON submission_items(approved_at)
|
||||
WHERE approved_at IS NOT NULL;
|
||||
|
||||
-- Add comment for documentation
|
||||
COMMENT ON COLUMN submission_items.approved_at IS
|
||||
'Timestamp when this specific item was approved by a moderator. NULL for pending/rejected items.';
|
||||
|
||||
-- Drop existing function to update parameter signature
|
||||
DROP FUNCTION IF EXISTS process_approval_transaction(UUID, UUID[], UUID, UUID, TEXT, TEXT, TEXT);
|
||||
|
||||
-- Recreate process_approval_transaction function with approved_at support
|
||||
CREATE OR REPLACE FUNCTION process_approval_transaction(
|
||||
p_submission_id UUID,
|
||||
p_item_ids UUID[],
|
||||
p_moderator_id UUID,
|
||||
p_submitter_id UUID,
|
||||
p_request_id TEXT DEFAULT NULL,
|
||||
p_approval_mode TEXT DEFAULT 'full',
|
||||
p_idempotency_key TEXT DEFAULT NULL
|
||||
) RETURNS JSONB AS $$
|
||||
DECLARE
|
||||
v_item RECORD;
|
||||
v_entity_id UUID;
|
||||
v_entity_type TEXT;
|
||||
v_action_type TEXT;
|
||||
v_item_data JSONB;
|
||||
v_approved_items JSONB := '[]'::JSONB;
|
||||
v_failed_items JSONB := '[]'::JSONB;
|
||||
v_submission_type TEXT;
|
||||
v_result JSONB;
|
||||
v_error_message TEXT;
|
||||
v_error_detail TEXT;
|
||||
v_start_time TIMESTAMP := clock_timestamp();
|
||||
v_duration_ms INTEGER;
|
||||
v_rollback_triggered BOOLEAN := FALSE;
|
||||
v_lock_acquired BOOLEAN := FALSE;
|
||||
BEGIN
|
||||
-- Validate moderator has permission
|
||||
IF NOT is_moderator(p_moderator_id) THEN
|
||||
RAISE EXCEPTION 'User % does not have moderator privileges', p_moderator_id
|
||||
USING ERRCODE = 'insufficient_privilege';
|
||||
END IF;
|
||||
|
||||
-- Get submission type
|
||||
SELECT submission_type INTO v_submission_type
|
||||
FROM content_submissions
|
||||
WHERE id = p_submission_id;
|
||||
|
||||
IF v_submission_type IS NULL THEN
|
||||
RAISE EXCEPTION 'Submission % not found', p_submission_id
|
||||
USING ERRCODE = 'no_data_found';
|
||||
END IF;
|
||||
|
||||
-- Acquire advisory lock
|
||||
IF NOT pg_try_advisory_xact_lock(hashtext(p_submission_id::TEXT)) THEN
|
||||
RAISE EXCEPTION 'Could not acquire lock for submission %', p_submission_id
|
||||
USING ERRCODE = '55P03';
|
||||
END IF;
|
||||
v_lock_acquired := TRUE;
|
||||
|
||||
-- Process each item
|
||||
FOR v_item IN
|
||||
SELECT si.*
|
||||
FROM submission_items si
|
||||
WHERE si.submission_id = p_submission_id
|
||||
AND si.id = ANY(p_item_ids)
|
||||
AND si.status = 'pending'
|
||||
ORDER BY si.order_index
|
||||
LOOP
|
||||
BEGIN
|
||||
v_entity_type := v_item.item_type;
|
||||
v_action_type := v_item.action_type;
|
||||
v_item_data := v_item.item_data;
|
||||
|
||||
-- Create/update entity based on type and action
|
||||
IF v_action_type = 'create' THEN
|
||||
IF v_entity_type = 'park' THEN
|
||||
INSERT INTO parks (name, slug, description, location_id, operator_id, property_owner_id)
|
||||
SELECT
|
||||
v_item_data->>'name',
|
||||
v_item_data->>'slug',
|
||||
v_item_data->>'description',
|
||||
(v_item_data->>'location_id')::UUID,
|
||||
(v_item_data->>'operator_id')::UUID,
|
||||
(v_item_data->>'property_owner_id')::UUID
|
||||
RETURNING id INTO v_entity_id;
|
||||
|
||||
ELSIF v_entity_type = 'ride' THEN
|
||||
INSERT INTO rides (name, slug, park_id, manufacturer_id, designer_id)
|
||||
SELECT
|
||||
v_item_data->>'name',
|
||||
v_item_data->>'slug',
|
||||
(v_item_data->>'park_id')::UUID,
|
||||
(v_item_data->>'manufacturer_id')::UUID,
|
||||
(v_item_data->>'designer_id')::UUID
|
||||
RETURNING id INTO v_entity_id;
|
||||
|
||||
ELSIF v_entity_type IN ('manufacturer', 'operator', 'designer', 'property_owner') THEN
|
||||
INSERT INTO companies (name, slug, company_type, description)
|
||||
SELECT
|
||||
v_item_data->>'name',
|
||||
v_item_data->>'slug',
|
||||
v_entity_type,
|
||||
v_item_data->>'description'
|
||||
RETURNING id INTO v_entity_id;
|
||||
|
||||
ELSE
|
||||
RAISE EXCEPTION 'Unsupported entity type: %', v_entity_type;
|
||||
END IF;
|
||||
|
||||
ELSIF v_action_type = 'edit' THEN
|
||||
v_entity_id := (v_item_data->>'entity_id')::UUID;
|
||||
|
||||
IF v_entity_type = 'park' THEN
|
||||
UPDATE parks SET
|
||||
name = COALESCE(v_item_data->>'name', name),
|
||||
description = COALESCE(v_item_data->>'description', description),
|
||||
location_id = COALESCE((v_item_data->>'location_id')::UUID, location_id),
|
||||
updated_at = now()
|
||||
WHERE id = v_entity_id;
|
||||
|
||||
ELSIF v_entity_type = 'ride' THEN
|
||||
UPDATE rides SET
|
||||
name = COALESCE(v_item_data->>'name', name),
|
||||
description = COALESCE(v_item_data->>'description', description),
|
||||
updated_at = now()
|
||||
WHERE id = v_entity_id;
|
||||
|
||||
ELSIF v_entity_type IN ('manufacturer', 'operator', 'designer', 'property_owner') THEN
|
||||
UPDATE companies SET
|
||||
name = COALESCE(v_item_data->>'name', name),
|
||||
description = COALESCE(v_item_data->>'description', description),
|
||||
updated_at = now()
|
||||
WHERE id = v_entity_id;
|
||||
END IF;
|
||||
END IF;
|
||||
|
||||
-- Update submission item with approved status and timestamp
|
||||
UPDATE submission_items
|
||||
SET
|
||||
approved_entity_id = v_entity_id,
|
||||
status = 'approved',
|
||||
approved_at = now(),
|
||||
updated_at = now()
|
||||
WHERE id = v_item.id;
|
||||
|
||||
-- Add to success list
|
||||
v_approved_items := v_approved_items || jsonb_build_object(
|
||||
'item_id', v_item.id,
|
||||
'entity_id', v_entity_id,
|
||||
'entity_type', v_entity_type
|
||||
);
|
||||
|
||||
EXCEPTION WHEN OTHERS THEN
|
||||
GET STACKED DIAGNOSTICS
|
||||
v_error_message = MESSAGE_TEXT,
|
||||
v_error_detail = PG_EXCEPTION_DETAIL;
|
||||
|
||||
-- Add to failed list
|
||||
v_failed_items := v_failed_items || jsonb_build_object(
|
||||
'item_id', v_item.id,
|
||||
'error', v_error_message,
|
||||
'detail', v_error_detail
|
||||
);
|
||||
|
||||
-- Mark item as failed
|
||||
UPDATE submission_items
|
||||
SET
|
||||
status = 'flagged',
|
||||
rejection_reason = v_error_message,
|
||||
updated_at = now()
|
||||
WHERE id = v_item.id;
|
||||
END;
|
||||
END LOOP;
|
||||
|
||||
-- Update submission status based on approval mode
|
||||
IF p_approval_mode = 'selective' THEN
|
||||
UPDATE content_submissions
|
||||
SET
|
||||
status = 'partially_approved',
|
||||
reviewed_at = now(),
|
||||
reviewer_id = p_moderator_id,
|
||||
updated_at = now()
|
||||
WHERE id = p_submission_id;
|
||||
ELSE
|
||||
UPDATE content_submissions
|
||||
SET
|
||||
status = 'approved',
|
||||
reviewed_at = now(),
|
||||
reviewer_id = p_moderator_id,
|
||||
resolved_at = now(),
|
||||
updated_at = now()
|
||||
WHERE id = p_submission_id;
|
||||
END IF;
|
||||
|
||||
-- Calculate duration
|
||||
v_duration_ms := EXTRACT(EPOCH FROM (clock_timestamp() - v_start_time)) * 1000;
|
||||
|
||||
-- Log metrics
|
||||
INSERT INTO approval_transaction_metrics (
|
||||
submission_id,
|
||||
moderator_id,
|
||||
submitter_id,
|
||||
items_count,
|
||||
success,
|
||||
duration_ms,
|
||||
request_id,
|
||||
rollback_triggered
|
||||
) VALUES (
|
||||
p_submission_id,
|
||||
p_moderator_id,
|
||||
p_submitter_id,
|
||||
jsonb_array_length(v_approved_items),
|
||||
jsonb_array_length(v_failed_items) = 0,
|
||||
v_duration_ms,
|
||||
p_request_id,
|
||||
v_rollback_triggered
|
||||
);
|
||||
|
||||
-- Build result
|
||||
v_result := jsonb_build_object(
|
||||
'success', TRUE,
|
||||
'approved_items', v_approved_items,
|
||||
'failed_items', v_failed_items,
|
||||
'duration_ms', v_duration_ms
|
||||
);
|
||||
|
||||
RETURN v_result;
|
||||
|
||||
EXCEPTION WHEN OTHERS THEN
|
||||
v_rollback_triggered := TRUE;
|
||||
GET STACKED DIAGNOSTICS
|
||||
v_error_message = MESSAGE_TEXT,
|
||||
v_error_detail = PG_EXCEPTION_DETAIL;
|
||||
|
||||
-- Log failed transaction
|
||||
v_duration_ms := EXTRACT(EPOCH FROM (clock_timestamp() - v_start_time)) * 1000;
|
||||
|
||||
INSERT INTO approval_transaction_metrics (
|
||||
submission_id,
|
||||
moderator_id,
|
||||
submitter_id,
|
||||
items_count,
|
||||
success,
|
||||
duration_ms,
|
||||
error_message,
|
||||
error_details,
|
||||
request_id,
|
||||
rollback_triggered
|
||||
) VALUES (
|
||||
p_submission_id,
|
||||
p_moderator_id,
|
||||
p_submitter_id,
|
||||
array_length(p_item_ids, 1),
|
||||
FALSE,
|
||||
v_duration_ms,
|
||||
v_error_message,
|
||||
v_error_detail,
|
||||
p_request_id,
|
||||
v_rollback_triggered
|
||||
);
|
||||
|
||||
RAISE;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql SECURITY DEFINER;
|
||||
@@ -0,0 +1,259 @@
|
||||
-- Fix security warning: Add search_path to process_approval_transaction
|
||||
CREATE OR REPLACE FUNCTION process_approval_transaction(
|
||||
p_submission_id UUID,
|
||||
p_item_ids UUID[],
|
||||
p_moderator_id UUID,
|
||||
p_submitter_id UUID,
|
||||
p_request_id TEXT DEFAULT NULL,
|
||||
p_approval_mode TEXT DEFAULT 'full',
|
||||
p_idempotency_key TEXT DEFAULT NULL
|
||||
) RETURNS JSONB
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
SET search_path TO 'public'
|
||||
AS $$
|
||||
DECLARE
|
||||
v_item RECORD;
|
||||
v_entity_id UUID;
|
||||
v_entity_type TEXT;
|
||||
v_action_type TEXT;
|
||||
v_item_data JSONB;
|
||||
v_approved_items JSONB := '[]'::JSONB;
|
||||
v_failed_items JSONB := '[]'::JSONB;
|
||||
v_submission_type TEXT;
|
||||
v_result JSONB;
|
||||
v_error_message TEXT;
|
||||
v_error_detail TEXT;
|
||||
v_start_time TIMESTAMP := clock_timestamp();
|
||||
v_duration_ms INTEGER;
|
||||
v_rollback_triggered BOOLEAN := FALSE;
|
||||
v_lock_acquired BOOLEAN := FALSE;
|
||||
BEGIN
|
||||
-- Validate moderator has permission
|
||||
IF NOT is_moderator(p_moderator_id) THEN
|
||||
RAISE EXCEPTION 'User % does not have moderator privileges', p_moderator_id
|
||||
USING ERRCODE = 'insufficient_privilege';
|
||||
END IF;
|
||||
|
||||
-- Get submission type
|
||||
SELECT submission_type INTO v_submission_type
|
||||
FROM content_submissions
|
||||
WHERE id = p_submission_id;
|
||||
|
||||
IF v_submission_type IS NULL THEN
|
||||
RAISE EXCEPTION 'Submission % not found', p_submission_id
|
||||
USING ERRCODE = 'no_data_found';
|
||||
END IF;
|
||||
|
||||
-- Acquire advisory lock
|
||||
IF NOT pg_try_advisory_xact_lock(hashtext(p_submission_id::TEXT)) THEN
|
||||
RAISE EXCEPTION 'Could not acquire lock for submission %', p_submission_id
|
||||
USING ERRCODE = '55P03';
|
||||
END IF;
|
||||
v_lock_acquired := TRUE;
|
||||
|
||||
-- Process each item
|
||||
FOR v_item IN
|
||||
SELECT si.*
|
||||
FROM submission_items si
|
||||
WHERE si.submission_id = p_submission_id
|
||||
AND si.id = ANY(p_item_ids)
|
||||
AND si.status = 'pending'
|
||||
ORDER BY si.order_index
|
||||
LOOP
|
||||
BEGIN
|
||||
v_entity_type := v_item.item_type;
|
||||
v_action_type := v_item.action_type;
|
||||
v_item_data := v_item.item_data;
|
||||
|
||||
-- Create/update entity based on type and action
|
||||
IF v_action_type = 'create' THEN
|
||||
IF v_entity_type = 'park' THEN
|
||||
INSERT INTO parks (name, slug, description, location_id, operator_id, property_owner_id)
|
||||
SELECT
|
||||
v_item_data->>'name',
|
||||
v_item_data->>'slug',
|
||||
v_item_data->>'description',
|
||||
(v_item_data->>'location_id')::UUID,
|
||||
(v_item_data->>'operator_id')::UUID,
|
||||
(v_item_data->>'property_owner_id')::UUID
|
||||
RETURNING id INTO v_entity_id;
|
||||
|
||||
ELSIF v_entity_type = 'ride' THEN
|
||||
INSERT INTO rides (name, slug, park_id, manufacturer_id, designer_id)
|
||||
SELECT
|
||||
v_item_data->>'name',
|
||||
v_item_data->>'slug',
|
||||
(v_item_data->>'park_id')::UUID,
|
||||
(v_item_data->>'manufacturer_id')::UUID,
|
||||
(v_item_data->>'designer_id')::UUID
|
||||
RETURNING id INTO v_entity_id;
|
||||
|
||||
ELSIF v_entity_type IN ('manufacturer', 'operator', 'designer', 'property_owner') THEN
|
||||
INSERT INTO companies (name, slug, company_type, description)
|
||||
SELECT
|
||||
v_item_data->>'name',
|
||||
v_item_data->>'slug',
|
||||
v_entity_type,
|
||||
v_item_data->>'description'
|
||||
RETURNING id INTO v_entity_id;
|
||||
|
||||
ELSE
|
||||
RAISE EXCEPTION 'Unsupported entity type: %', v_entity_type;
|
||||
END IF;
|
||||
|
||||
ELSIF v_action_type = 'edit' THEN
|
||||
v_entity_id := (v_item_data->>'entity_id')::UUID;
|
||||
|
||||
IF v_entity_type = 'park' THEN
|
||||
UPDATE parks SET
|
||||
name = COALESCE(v_item_data->>'name', name),
|
||||
description = COALESCE(v_item_data->>'description', description),
|
||||
location_id = COALESCE((v_item_data->>'location_id')::UUID, location_id),
|
||||
updated_at = now()
|
||||
WHERE id = v_entity_id;
|
||||
|
||||
ELSIF v_entity_type = 'ride' THEN
|
||||
UPDATE rides SET
|
||||
name = COALESCE(v_item_data->>'name', name),
|
||||
description = COALESCE(v_item_data->>'description', description),
|
||||
updated_at = now()
|
||||
WHERE id = v_entity_id;
|
||||
|
||||
ELSIF v_entity_type IN ('manufacturer', 'operator', 'designer', 'property_owner') THEN
|
||||
UPDATE companies SET
|
||||
name = COALESCE(v_item_data->>'name', name),
|
||||
description = COALESCE(v_item_data->>'description', description),
|
||||
updated_at = now()
|
||||
WHERE id = v_entity_id;
|
||||
END IF;
|
||||
END IF;
|
||||
|
||||
-- Update submission item with approved status and timestamp
|
||||
UPDATE submission_items
|
||||
SET
|
||||
approved_entity_id = v_entity_id,
|
||||
status = 'approved',
|
||||
approved_at = now(),
|
||||
updated_at = now()
|
||||
WHERE id = v_item.id;
|
||||
|
||||
-- Add to success list
|
||||
v_approved_items := v_approved_items || jsonb_build_object(
|
||||
'item_id', v_item.id,
|
||||
'entity_id', v_entity_id,
|
||||
'entity_type', v_entity_type
|
||||
);
|
||||
|
||||
EXCEPTION WHEN OTHERS THEN
|
||||
GET STACKED DIAGNOSTICS
|
||||
v_error_message = MESSAGE_TEXT,
|
||||
v_error_detail = PG_EXCEPTION_DETAIL;
|
||||
|
||||
-- Add to failed list
|
||||
v_failed_items := v_failed_items || jsonb_build_object(
|
||||
'item_id', v_item.id,
|
||||
'error', v_error_message,
|
||||
'detail', v_error_detail
|
||||
);
|
||||
|
||||
-- Mark item as failed
|
||||
UPDATE submission_items
|
||||
SET
|
||||
status = 'flagged',
|
||||
rejection_reason = v_error_message,
|
||||
updated_at = now()
|
||||
WHERE id = v_item.id;
|
||||
END;
|
||||
END LOOP;
|
||||
|
||||
-- Update submission status based on approval mode
|
||||
IF p_approval_mode = 'selective' THEN
|
||||
UPDATE content_submissions
|
||||
SET
|
||||
status = 'partially_approved',
|
||||
reviewed_at = now(),
|
||||
reviewer_id = p_moderator_id,
|
||||
updated_at = now()
|
||||
WHERE id = p_submission_id;
|
||||
ELSE
|
||||
UPDATE content_submissions
|
||||
SET
|
||||
status = 'approved',
|
||||
reviewed_at = now(),
|
||||
reviewer_id = p_moderator_id,
|
||||
resolved_at = now(),
|
||||
updated_at = now()
|
||||
WHERE id = p_submission_id;
|
||||
END IF;
|
||||
|
||||
-- Calculate duration
|
||||
v_duration_ms := EXTRACT(EPOCH FROM (clock_timestamp() - v_start_time)) * 1000;
|
||||
|
||||
-- Log metrics
|
||||
INSERT INTO approval_transaction_metrics (
|
||||
submission_id,
|
||||
moderator_id,
|
||||
submitter_id,
|
||||
items_count,
|
||||
success,
|
||||
duration_ms,
|
||||
request_id,
|
||||
rollback_triggered
|
||||
) VALUES (
|
||||
p_submission_id,
|
||||
p_moderator_id,
|
||||
p_submitter_id,
|
||||
jsonb_array_length(v_approved_items),
|
||||
jsonb_array_length(v_failed_items) = 0,
|
||||
v_duration_ms,
|
||||
p_request_id,
|
||||
v_rollback_triggered
|
||||
);
|
||||
|
||||
-- Build result
|
||||
v_result := jsonb_build_object(
|
||||
'success', TRUE,
|
||||
'approved_items', v_approved_items,
|
||||
'failed_items', v_failed_items,
|
||||
'duration_ms', v_duration_ms
|
||||
);
|
||||
|
||||
RETURN v_result;
|
||||
|
||||
EXCEPTION WHEN OTHERS THEN
|
||||
v_rollback_triggered := TRUE;
|
||||
GET STACKED DIAGNOSTICS
|
||||
v_error_message = MESSAGE_TEXT,
|
||||
v_error_detail = PG_EXCEPTION_DETAIL;
|
||||
|
||||
-- Log failed transaction
|
||||
v_duration_ms := EXTRACT(EPOCH FROM (clock_timestamp() - v_start_time)) * 1000;
|
||||
|
||||
INSERT INTO approval_transaction_metrics (
|
||||
submission_id,
|
||||
moderator_id,
|
||||
submitter_id,
|
||||
items_count,
|
||||
success,
|
||||
duration_ms,
|
||||
error_message,
|
||||
error_details,
|
||||
request_id,
|
||||
rollback_triggered
|
||||
) VALUES (
|
||||
p_submission_id,
|
||||
p_moderator_id,
|
||||
p_submitter_id,
|
||||
array_length(p_item_ids, 1),
|
||||
FALSE,
|
||||
v_duration_ms,
|
||||
v_error_message,
|
||||
v_error_detail,
|
||||
p_request_id,
|
||||
v_rollback_triggered
|
||||
);
|
||||
|
||||
RAISE;
|
||||
END;
|
||||
$$;
|
||||
@@ -0,0 +1,158 @@
|
||||
-- Create materialized view for approval history with detailed audit trail
|
||||
CREATE MATERIALIZED VIEW approval_history_detailed AS
|
||||
SELECT
|
||||
si.id as item_id,
|
||||
si.submission_id,
|
||||
si.item_type,
|
||||
si.action_type,
|
||||
si.status,
|
||||
si.approved_at,
|
||||
si.approved_entity_id,
|
||||
si.created_at,
|
||||
si.updated_at,
|
||||
-- Calculate approval duration (seconds)
|
||||
EXTRACT(EPOCH FROM (si.approved_at - si.created_at)) as approval_time_seconds,
|
||||
-- Submission context
|
||||
cs.submission_type,
|
||||
cs.user_id as submitter_id,
|
||||
cs.reviewer_id as approver_id,
|
||||
cs.submitted_at,
|
||||
-- Submitter profile
|
||||
p_submitter.username as submitter_username,
|
||||
p_submitter.display_name as submitter_display_name,
|
||||
p_submitter.avatar_url as submitter_avatar_url,
|
||||
-- Approver profile
|
||||
p_approver.username as approver_username,
|
||||
p_approver.display_name as approver_display_name,
|
||||
p_approver.avatar_url as approver_avatar_url,
|
||||
-- Entity slugs for linking (dynamic based on item_type)
|
||||
CASE
|
||||
WHEN si.item_type = 'park' THEN (SELECT slug FROM parks WHERE id = si.approved_entity_id)
|
||||
WHEN si.item_type = 'ride' THEN (SELECT slug FROM rides WHERE id = si.approved_entity_id)
|
||||
WHEN si.item_type = 'manufacturer' THEN (SELECT slug FROM companies WHERE id = si.approved_entity_id AND company_type = 'manufacturer')
|
||||
WHEN si.item_type = 'designer' THEN (SELECT slug FROM companies WHERE id = si.approved_entity_id AND company_type = 'designer')
|
||||
WHEN si.item_type = 'operator' THEN (SELECT slug FROM companies WHERE id = si.approved_entity_id AND company_type = 'operator')
|
||||
WHEN si.item_type = 'ride_model' THEN (SELECT slug FROM ride_models WHERE id = si.approved_entity_id)
|
||||
ELSE NULL
|
||||
END as entity_slug,
|
||||
-- Entity names for display
|
||||
CASE
|
||||
WHEN si.item_type = 'park' THEN (SELECT name FROM parks WHERE id = si.approved_entity_id)
|
||||
WHEN si.item_type = 'ride' THEN (SELECT name FROM rides WHERE id = si.approved_entity_id)
|
||||
WHEN si.item_type = 'manufacturer' THEN (SELECT name FROM companies WHERE id = si.approved_entity_id AND company_type = 'manufacturer')
|
||||
WHEN si.item_type = 'designer' THEN (SELECT name FROM companies WHERE id = si.approved_entity_id AND company_type = 'designer')
|
||||
WHEN si.item_type = 'operator' THEN (SELECT name FROM companies WHERE id = si.approved_entity_id AND company_type = 'operator')
|
||||
WHEN si.item_type = 'ride_model' THEN (SELECT name FROM ride_models WHERE id = si.approved_entity_id)
|
||||
ELSE NULL
|
||||
END as entity_name
|
||||
FROM submission_items si
|
||||
JOIN content_submissions cs ON cs.id = si.submission_id
|
||||
LEFT JOIN profiles p_submitter ON p_submitter.user_id = cs.user_id
|
||||
LEFT JOIN profiles p_approver ON p_approver.user_id = cs.reviewer_id
|
||||
WHERE si.approved_at IS NOT NULL
|
||||
AND si.status = 'approved'
|
||||
ORDER BY si.approved_at DESC;
|
||||
|
||||
-- Create indexes for fast lookups
|
||||
CREATE INDEX idx_approval_history_approved_at ON approval_history_detailed(approved_at DESC);
|
||||
CREATE INDEX idx_approval_history_item_type ON approval_history_detailed(item_type);
|
||||
CREATE INDEX idx_approval_history_approver ON approval_history_detailed(approver_id);
|
||||
CREATE INDEX idx_approval_history_submitter ON approval_history_detailed(submitter_id);
|
||||
|
||||
-- Function to refresh the materialized view
|
||||
CREATE OR REPLACE FUNCTION refresh_approval_history()
|
||||
RETURNS void
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
SET search_path TO 'public'
|
||||
AS $$
|
||||
BEGIN
|
||||
REFRESH MATERIALIZED VIEW CONCURRENTLY approval_history_detailed;
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- Security-definer function to query approval history (moderators only)
|
||||
CREATE OR REPLACE FUNCTION get_approval_history(
|
||||
p_item_type text DEFAULT NULL,
|
||||
p_approver_id uuid DEFAULT NULL,
|
||||
p_from_date timestamptz DEFAULT NULL,
|
||||
p_to_date timestamptz DEFAULT NULL,
|
||||
p_limit integer DEFAULT 100,
|
||||
p_offset integer DEFAULT 0
|
||||
)
|
||||
RETURNS TABLE (
|
||||
item_id uuid,
|
||||
submission_id uuid,
|
||||
item_type text,
|
||||
action_type text,
|
||||
status text,
|
||||
approved_at timestamptz,
|
||||
approved_entity_id uuid,
|
||||
created_at timestamptz,
|
||||
updated_at timestamptz,
|
||||
approval_time_seconds numeric,
|
||||
submission_type text,
|
||||
submitter_id uuid,
|
||||
approver_id uuid,
|
||||
submitted_at timestamptz,
|
||||
submitter_username text,
|
||||
submitter_display_name text,
|
||||
submitter_avatar_url text,
|
||||
approver_username text,
|
||||
approver_display_name text,
|
||||
approver_avatar_url text,
|
||||
entity_slug text,
|
||||
entity_name text
|
||||
)
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
SET search_path TO 'public'
|
||||
AS $$
|
||||
BEGIN
|
||||
-- Check if user is a moderator
|
||||
IF NOT is_moderator(auth.uid()) THEN
|
||||
RAISE EXCEPTION 'Access denied: Moderator role required';
|
||||
END IF;
|
||||
|
||||
-- Return filtered results
|
||||
RETURN QUERY
|
||||
SELECT
|
||||
ahd.item_id,
|
||||
ahd.submission_id,
|
||||
ahd.item_type,
|
||||
ahd.action_type,
|
||||
ahd.status,
|
||||
ahd.approved_at,
|
||||
ahd.approved_entity_id,
|
||||
ahd.created_at,
|
||||
ahd.updated_at,
|
||||
ahd.approval_time_seconds,
|
||||
ahd.submission_type,
|
||||
ahd.submitter_id,
|
||||
ahd.approver_id,
|
||||
ahd.submitted_at,
|
||||
ahd.submitter_username,
|
||||
ahd.submitter_display_name,
|
||||
ahd.submitter_avatar_url,
|
||||
ahd.approver_username,
|
||||
ahd.approver_display_name,
|
||||
ahd.approver_avatar_url,
|
||||
ahd.entity_slug,
|
||||
ahd.entity_name
|
||||
FROM approval_history_detailed ahd
|
||||
WHERE (p_item_type IS NULL OR ahd.item_type = p_item_type)
|
||||
AND (p_approver_id IS NULL OR ahd.approver_id = p_approver_id)
|
||||
AND (p_from_date IS NULL OR ahd.approved_at >= p_from_date)
|
||||
AND (p_to_date IS NULL OR ahd.approved_at < p_to_date + interval '1 day')
|
||||
ORDER BY ahd.approved_at DESC
|
||||
LIMIT p_limit
|
||||
OFFSET p_offset;
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- Grant execute permission to authenticated users (function checks moderator role internally)
|
||||
GRANT EXECUTE ON FUNCTION get_approval_history TO authenticated;
|
||||
|
||||
COMMENT ON MATERIALIZED VIEW approval_history_detailed IS 'Materialized view storing approval history data - access via get_approval_history() function';
|
||||
COMMENT ON FUNCTION refresh_approval_history() IS 'Refreshes the approval history materialized view - call after bulk approvals';
|
||||
COMMENT ON FUNCTION get_approval_history IS 'Query approval history with filters - moderators only';
|
||||
@@ -0,0 +1,12 @@
|
||||
-- Add moderation_preferences column to user_preferences table
|
||||
-- This stores moderator UI preferences like detailed view collapsed state
|
||||
|
||||
ALTER TABLE public.user_preferences
|
||||
ADD COLUMN IF NOT EXISTS moderation_preferences JSONB NOT NULL DEFAULT '{}'::jsonb;
|
||||
|
||||
COMMENT ON COLUMN public.user_preferences.moderation_preferences IS
|
||||
'Stores moderator UI preferences like detailed view collapsed state';
|
||||
|
||||
-- Add GIN index for efficient JSONB queries
|
||||
CREATE INDEX IF NOT EXISTS idx_user_preferences_moderation_prefs
|
||||
ON public.user_preferences USING gin(moderation_preferences);
|
||||
Reference in New Issue
Block a user