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