From b22546e7f20c3e0c299786c3de7c366009f7cfcf Mon Sep 17 00:00:00 2001 From: "gpt-engineer-app[bot]" <159125892+gpt-engineer-app[bot]@users.noreply.github.com> Date: Wed, 12 Nov 2025 14:06:34 +0000 Subject: [PATCH] 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. --- src/App.tsx | 11 +- .../moderation/ActiveFiltersDisplay.tsx | 19 +- .../moderation/ItemApprovalHistory.tsx | 321 ++++++++++++++++++ src/components/moderation/ModerationQueue.tsx | 4 + src/components/moderation/QueueFilters.tsx | 27 +- src/hooks/moderation/useModerationFilters.ts | 38 ++- .../moderation/useModerationQueueManager.ts | 1 + src/hooks/moderation/useQueueQuery.ts | 9 + src/integrations/supabase/types.ts | 133 ++++++++ src/lib/moderation/queries.ts | 17 +- src/pages/admin/ApprovalHistory.tsx | 136 ++++++++ src/types/moderation.ts | 8 + ...9_f49e9ec3-ed7d-40ef-88ea-e2b2ea85dca4.sql | 158 +++++++++ 13 files changed, 872 insertions(+), 10 deletions(-) create mode 100644 src/components/moderation/ItemApprovalHistory.tsx create mode 100644 src/pages/admin/ApprovalHistory.tsx create mode 100644 supabase/migrations/20251112140129_f49e9ec3-ed7d-40ef-88ea-e2b2ea85dca4.sql diff --git a/src/App.tsx b/src/App.tsx index 23da0412..d3b36a9e 100644 --- a/src/App.tsx +++ b/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 { } /> + + + } + /> + diff --git a/src/components/moderation/ActiveFiltersDisplay.tsx b/src/components/moderation/ActiveFiltersDisplay.tsx index 6b85793a..ee981d44 100644 --- a/src/components/moderation/ActiveFiltersDisplay.tsx +++ b/src/components/moderation/ActiveFiltersDisplay.tsx @@ -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} )} + {hasDateRange && ( + + + {approvalDateRange.from && format(approvalDateRange.from, 'MMM d')} + {approvalDateRange.from && approvalDateRange.to && ' - '} + {approvalDateRange.to && format(approvalDateRange.to, 'MMM d')} + + )} ); }; diff --git a/src/components/moderation/ItemApprovalHistory.tsx b/src/components/moderation/ItemApprovalHistory.tsx new file mode 100644 index 00000000..282043dc --- /dev/null +++ b/src/components/moderation/ItemApprovalHistory.tsx @@ -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 ( + + +

Failed to load approval history. Please try again.

+
+
+ ); + } + + const content = ( + <> + {!embedded && ( + +
+
+ Item Approval History + Detailed audit trail of approved submissions +
+ {sortedHistory.length > 0 && ( + + )} +
+
+ )} + + {isLoading ? ( +
+ {[...Array(5)].map((_, i) => ( + + ))} +
+ ) : sortedHistory.length === 0 ? ( +
+ +

No approval history found

+
+ ) : ( +
+ + + + handleSort('approved_at')} + > + Approved At {sortField === 'approved_at' && (sortDirection === 'asc' ? '↑' : '↓')} + + Type + Entity + Submitter + Approver + handleSort('approval_time_seconds')} + > + Time to Approve {sortField === 'approval_time_seconds' && (sortDirection === 'asc' ? '↑' : '↓')} + + + + + {sortedHistory.map((item) => { + const speed = getApprovalSpeed(item.approval_time_seconds); + const entityPath = getEntityPath(item.item_type, item.entity_slug); + + return ( + + +
+ {format(new Date(item.approved_at), 'MMM d, yyyy')} + {format(new Date(item.approved_at), 'HH:mm:ss')} +
+
+ + + {item.item_type} + + + + {item.entity_name ? ( +
+ {item.entity_name} + {entityPath && ( + + + + )} +
+ ) : ( + N/A + )} +
+ +
+ + + + {(item.submitter_display_name || item.submitter_username || 'U')[0].toUpperCase()} + + + {item.submitter_display_name || item.submitter_username || 'Unknown'} +
+
+ +
+ + + + {(item.approver_display_name || item.approver_username || 'M')[0].toUpperCase()} + + + {item.approver_display_name || item.approver_username || 'Unknown'} +
+
+ +
+ + {formatDuration(item.approval_time_seconds)} + + {speed.label} + +
+
+
+ ); + })} +
+
+
+ )} +
+ + ); + + return embedded ? content : {content}; +}; \ No newline at end of file diff --git a/src/components/moderation/ModerationQueue.tsx b/src/components/moderation/ModerationQueue.tsx index 7cfa0026..1ef84b05 100644 --- a/src/components/moderation/ModerationQueue.tsx +++ b/src/components/moderation/ModerationQueue.tsx @@ -501,11 +501,14 @@ export const ModerationQueue = forwardRef )} diff --git a/src/components/moderation/QueueFilters.tsx b/src/components/moderation/QueueFilters.tsx index 188068bd..a329f56d 100644 --- a/src/components/moderation/QueueFilters.tsx +++ b/src/components/moderation/QueueFilters.tsx @@ -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' && ( +
+ onApprovalDateRangeChange({ ...approvalDateRange, from: date || null })} + onToChange={(date) => onApprovalDateRangeChange({ ...approvalDateRange, to: date || null })} + fromPlaceholder="Start Date" + toPlaceholder="End Date" + /> +
+ )} {/* Clear Filters & Apply Buttons (mobile only) */} diff --git a/src/hooks/moderation/useModerationFilters.ts b/src/hooks/moderation/useModerationFilters.ts index 6888ffdb..1f89cfb9 100644 --- a/src/hooks/moderation/useModerationFilters.ts +++ b/src/hooks/moderation/useModerationFilters.ts @@ -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(loadPersistedSort); + + // Approval date range state + const [approvalDateRange, setApprovalDateRangeState] = useState(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, }; } diff --git a/src/hooks/moderation/useModerationQueueManager.ts b/src/hooks/moderation/useModerationQueueManager.ts index 531b7189..72be9e66 100644 --- a/src/hooks/moderation/useModerationQueueManager.ts +++ b/src/hooks/moderation/useModerationQueueManager.ts @@ -174,6 +174,7 @@ export function useModerationQueueManager(config: ModerationQueueManagerConfig): currentPage: pagination.currentPage, pageSize: pagination.pageSize, sortConfig: filters.debouncedSortConfig, + approvalDateRange: filters.debouncedApprovalDateRange, enabled: !!user, }); diff --git a/src/hooks/moderation/useQueueQuery.ts b/src/hooks/moderation/useQueueQuery.ts index 3fe00c10..a658c2a5 100644 --- a/src/hooks/moderation/useQueueQuery.ts +++ b/src/hooks/moderation/useQueueQuery.ts @@ -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 diff --git a/src/integrations/supabase/types.ts b/src/integrations/supabase/types.ts index ee16724c..dc6e1ce9 100644 --- a/src/integrations/supabase/types.ts +++ b/src/integrations/supabase/types.ts @@ -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"] @@ -5763,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"] @@ -5934,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"] @@ -6309,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 @@ -6834,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 } @@ -7102,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 } diff --git a/src/lib/moderation/queries.ts b/src/lib/moderation/queries.ts index 5b47111d..86343e27 100644 --- a/src/lib/moderation/queries.ts +++ b/src/lib/moderation/queries.ts @@ -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 diff --git a/src/pages/admin/ApprovalHistory.tsx b/src/pages/admin/ApprovalHistory.tsx new file mode 100644 index 00000000..3d1d5eb9 --- /dev/null +++ b/src/pages/admin/ApprovalHistory.tsx @@ -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(null); + const [toDate, setToDate] = useState(null); + const [itemType, setItemType] = useState('all'); + const [limit, setLimit] = useState(100); + + // Access control: moderators only + if (rolesLoading) { + return ( +
+
Loading...
+
+ ); + } + + if (!isModerator()) { + return ; + } + + const hasFilters = fromDate || toDate || itemType !== 'all' || limit !== 100; + + const clearFilters = () => { + setFromDate(null); + setToDate(null); + setItemType('all'); + setLimit(100); + }; + + return ( +
+ {/* Header */} +
+
+ +

Approval History

+
+

+ Complete audit trail of all approved items with exact timestamps for compliance reporting +

+
+ + {/* Filters */} + + +
+ {/* Date Range Filter */} +
+ setFromDate(date || null)} + onToChange={(date) => setToDate(date || null)} + fromPlaceholder="Start Date" + toPlaceholder="End Date" + /> +
+ + {/* Item Type Filter */} +
+ + +
+ + {/* Results Limit */} +
+ + +
+
+ + {/* Clear Filters */} + {hasFilters && ( +
+ +
+ )} +
+
+ + {/* History Table */} + +
+ ); +} \ No newline at end of file diff --git a/src/types/moderation.ts b/src/types/moderation.ts index 7bef1ab5..606b31cf 100644 --- a/src/types/moderation.ts +++ b/src/types/moderation.ts @@ -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 */ diff --git a/supabase/migrations/20251112140129_f49e9ec3-ed7d-40ef-88ea-e2b2ea85dca4.sql b/supabase/migrations/20251112140129_f49e9ec3-ed7d-40ef-88ea-e2b2ea85dca4.sql new file mode 100644 index 00000000..15979c23 --- /dev/null +++ b/supabase/migrations/20251112140129_f49e9ec3-ed7d-40ef-88ea-e2b2ea85dca4.sql @@ -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'; \ No newline at end of file