Compare commits

...

6 Commits

Author SHA1 Message Date
gpt-engineer-app[bot]
c8bea4b798 Changes 2025-11-12 14:36:37 +00:00
gpt-engineer-app[bot]
250e7c488a Changes 2025-11-12 14:36:07 +00:00
gpt-engineer-app[bot]
46c08e10e8 Add item-level approval history
Introduce ItemLevelApprovalHistory component to display which specific submission items were approved, when, and by whom, and integrate it into QueueItem between metadata and audit trail. The component shows item names, approval timestamps, action types, and reviewer info.
2025-11-12 14:28:50 +00:00
gpt-engineer-app[bot]
b22546e7f2 Add audit trail and filters
Implements audit trail view for item approvals, adds approval date range filtering to moderation queue, and wires up UI and backend components (Approval History page, ItemApprovalHistory component, materialized view-based history, and query/filters integration) to support compliant reporting and time-based moderation filtering.
2025-11-12 14:06:34 +00:00
gpt-engineer-app[bot]
7b0825e772 Add approved_at support
Added approved_at column to submission_items, updated process_approval_transaction to set approved_at on approvals, and updated TypeScript types to include approved_at. migrations and generated types updated accordingly.
2025-11-12 13:45:30 +00:00
gpt-engineer-app[bot]
1a57b4f33f Add approved_at support
- Add approved_at column to submission_items and index
- Update process_approval_transaction to set approved_at on approval
- Extend TypeScript types to include approved_at for submission items
2025-11-12 13:40:07 +00:00
20 changed files with 1657 additions and 37 deletions

View File

@@ -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"));
@@ -386,6 +387,14 @@ function AppContent(): React.JSX.Element {
</AdminErrorBoundary>
}
/>
<Route
path="/admin/approval-history"
element={
<AdminErrorBoundary section="Approval History">
<ApprovalHistory />
</AdminErrorBoundary>
}
/>
<Route
path="/admin/error-lookup"
element={

View File

@@ -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>
);
};

View File

@@ -0,0 +1,54 @@
import { ChevronDown, ChevronUp } from 'lucide-react';
import { Collapsible, CollapsibleTrigger, CollapsibleContent } from '@/components/ui/collapsible';
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils';
interface DetailedViewCollapsibleProps {
isCollapsed: boolean;
onToggle: () => void;
children: React.ReactNode;
className?: string;
}
/**
* Collapsible wrapper for detailed field-by-field view sections
* Provides expand/collapse functionality with visual indicators
*/
export function DetailedViewCollapsible({
isCollapsed,
onToggle,
children,
className
}: DetailedViewCollapsibleProps) {
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"
>
<div className="text-xs font-semibold text-muted-foreground uppercase tracking-wide">
All Fields (Detailed View)
</div>
<div className="flex items-center gap-2">
<span className="text-xs text-muted-foreground normal-case font-normal">
{isCollapsed ? 'Show' : 'Hide'}
</span>
{isCollapsed ? (
<ChevronDown className="h-4 w-4 text-muted-foreground" />
) : (
<ChevronUp className="h-4 w-4 text-muted-foreground" />
)}
</div>
</Button>
</CollapsibleTrigger>
<CollapsibleContent className="mt-3">
{children}
</CollapsibleContent>
</div>
</Collapsible>
);
}

View 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>;
};

View 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';

View File

@@ -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}
/>
)}

View File

@@ -1,4 +1,4 @@
import { Filter, MessageSquare, FileText, Image, X, ChevronDown } from 'lucide-react';
import { Filter, MessageSquare, FileText, Image, X, ChevronDown, Calendar } 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';
@@ -7,17 +7,21 @@ import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/component
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 { 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,11 +41,14 @@ export const QueueFilters = ({
activeEntityFilter,
activeStatusFilter,
sortConfig,
activeTab,
approvalDateRange,
isMobile,
isLoading = false,
onEntityFilterChange,
onStatusFilterChange,
onSortChange,
onApprovalDateRangeChange,
onClearFilters,
showClearButton,
onRefresh,
@@ -53,6 +60,7 @@ export const QueueFilters = ({
const activeFilterCount = [
activeEntityFilter !== 'all' ? 1 : 0,
activeStatusFilter !== 'all' ? 1 : 0,
approvalDateRange.from || approvalDateRange.to ? 1 : 0,
].reduce((sum, val) => sum + val, 0);
return (
@@ -164,6 +172,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) */}

View File

@@ -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>
)}

View File

@@ -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,6 +36,7 @@ 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();
@@ -188,17 +191,14 @@ 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}>
<SubmissionChangesDisplay
item={item}
view="detailed"
showImages={showImages}
submissionId={submissionId}
/>
</div>
</DetailedViewCollapsible>
</>
);
}
@@ -211,17 +211,14 @@ 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}>
<SubmissionChangesDisplay
item={item}
view="detailed"
showImages={showImages}
submissionId={submissionId}
/>
</div>
</DetailedViewCollapsible>
</>
);
}
@@ -234,17 +231,14 @@ 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}>
<SubmissionChangesDisplay
item={item}
view="detailed"
showImages={showImages}
submissionId={submissionId}
/>
</div>
</DetailedViewCollapsible>
</>
);
}
@@ -257,17 +251,14 @@ 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}>
<SubmissionChangesDisplay
item={item}
view="detailed"
showImages={showImages}
submissionId={submissionId}
/>
</div>
</DetailedViewCollapsible>
</>
);
}
@@ -280,17 +271,14 @@ 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}>
<SubmissionChangesDisplay
item={item}
view="detailed"
showImages={showImages}
submissionId={submissionId}
/>
</div>
</DetailedViewCollapsible>
</>
);
}

View File

@@ -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;
@@ -175,6 +188,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);
const debouncedStatusFilter = useDebounce(statusFilter, debounceDelay);
@@ -182,6 +198,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(() => {
if (persist) {
@@ -247,6 +266,13 @@ export function useModerationFilters(
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(() => {
logger.log('🔍 Filters cleared');
@@ -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,
};
}

View File

@@ -174,6 +174,7 @@ export function useModerationQueueManager(config: ModerationQueueManagerConfig):
currentPage: pagination.currentPage,
pageSize: pagination.pageSize,
sortConfig: filters.debouncedSortConfig,
approvalDateRange: filters.debouncedApprovalDateRange,
enabled: !!user,
});

View File

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

View File

@@ -0,0 +1,48 @@
import { useState, useEffect } from 'react';
import { logger } from '@/lib/logger';
const STORAGE_KEY = 'detailed-view-collapsed';
interface UseDetailedViewStateReturn {
isCollapsed: boolean;
toggle: () => void;
setCollapsed: (value: boolean) => void;
}
/**
* Hook to manage detailed view collapsed/expanded state
* Syncs with localStorage for persistence across sessions
* Defaults to collapsed to reduce visual clutter
*/
export function useDetailedViewState(): UseDetailedViewStateReturn {
const [isCollapsed, setIsCollapsed] = useState<boolean>(() => {
// Initialize from localStorage on mount
try {
const stored = localStorage.getItem(STORAGE_KEY);
// Default to collapsed (true) to reduce visual clutter
return stored ? JSON.parse(stored) : true;
} catch (error) {
logger.warn('Error reading detailed view state from localStorage', { error });
return true;
}
});
// Sync to localStorage when state changes
useEffect(() => {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(isCollapsed));
} catch (error) {
logger.warn('Error saving detailed view state to localStorage', { error });
}
}, [isCollapsed]);
const toggle = () => setIsCollapsed(prev => !prev);
const setCollapsed = (value: boolean) => setIsCollapsed(value);
return {
isCollapsed,
toggle,
setCollapsed,
};
}

View File

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

View File

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

View 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>
);
}

View File

@@ -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
*/

View File

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

View File

@@ -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;
$$;

View File

@@ -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';