import { useState, useEffect, useRef, useCallback } from 'react'; import { supabase } from '@/lib/supabaseClient'; import { toast } from 'sonner'; import { getErrorMessage, handleNonCriticalError, handleError } from '@/lib/errorHandler'; import type { EntityType, EntityVersion } from '@/types/versioning'; import { logger } from '@/lib/logger'; interface FieldChange { id: string; field_name: string; old_value: any; new_value: any; change_type: 'added' | 'modified' | 'removed'; created_at: string; } /** * Hook to manage entity versions using relational version tables * NO JSONB - Pure relational structure for type safety and queryability */ export function useEntityVersions(entityType: EntityType, entityId: string) { const [versions, setVersions] = useState([]); const [currentVersion, setCurrentVersion] = useState(null); const [loading, setLoading] = useState(true); const [fieldHistory, setFieldHistory] = useState([]); const isMountedRef = useRef(true); const channelRef = useRef | null>(null); const requestCounterRef = useRef(0); const fieldHistoryRequestCounterRef = useRef(0); const fetchVersions = useCallback(async () => { if (!isMountedRef.current) return; const currentRequestId = ++requestCounterRef.current; try { if (isMountedRef.current && currentRequestId === requestCounterRef.current) { setLoading(true); } // Build table and column names const versionTable = `${entityType}_versions`; const entityIdCol = `${entityType}_id`; let data, error; // Use explicit conditional branches for type safety if (entityType === 'park') { const result = await supabase .from('park_versions') .select(`*, profiles:created_by(username, display_name, avatar_url)`) .eq('park_id', entityId) .order('version_number', { ascending: false }); data = result.data; error = result.error; } else if (entityType === 'ride') { const result = await supabase .from('ride_versions') .select(`*, profiles:created_by(username, display_name, avatar_url)`) .eq('ride_id', entityId) .order('version_number', { ascending: false }); data = result.data; error = result.error; } else if (entityType === 'company') { const result = await supabase .from('company_versions') .select(`*, profiles:created_by(username, display_name, avatar_url)`) .eq('company_id', entityId) .order('version_number', { ascending: false }); data = result.data; error = result.error; } else { const result = await supabase .from('ride_model_versions') .select(`*, profiles:created_by(username, display_name, avatar_url)`) .eq('ride_model_id', entityId) .order('version_number', { ascending: false }); data = result.data; error = result.error; } if (error) throw error; if (!isMountedRef.current || currentRequestId !== requestCounterRef.current) return; if (!Array.isArray(data)) { if (isMountedRef.current && currentRequestId === requestCounterRef.current) { setVersions([]); setCurrentVersion(null); setLoading(false); } return; } interface VersionWithProfile { profiles?: { username: string; display_name: string; avatar_url: string | null; }; [key: string]: unknown; } const versionsWithProfiles = (data || []).map((v: VersionWithProfile) => ({ ...v, profiles: v.profiles || { username: 'Unknown', display_name: 'Unknown', avatar_url: null } })) as EntityVersion[]; if (isMountedRef.current && currentRequestId === requestCounterRef.current) { setVersions(versionsWithProfiles); setCurrentVersion(versionsWithProfiles.find(v => v.is_current) || null); setLoading(false); } } catch (error: unknown) { handleNonCriticalError(error, { action: 'Fetch entity versions', metadata: { entityType, entityId }, }); if (isMountedRef.current && currentRequestId === requestCounterRef.current) { toast.error(getErrorMessage(error)); setLoading(false); } } }, [entityType, entityId]); /** * Field history has been removed - use version comparison instead * This function is kept for backward compatibility but does nothing * @deprecated Use compareVersions() to see field-level changes */ const fetchFieldHistory = async (versionId: string) => { logger.warn('fetchFieldHistory is deprecated. Use compareVersions() instead for field-level changes.'); setFieldHistory([]); }; const compareVersions = async (fromVersionId: string, toVersionId: string) => { try { const { data, error } = await supabase.rpc('get_version_diff', { p_entity_type: entityType, p_from_version_id: fromVersionId, p_to_version_id: toVersionId }); if (error) throw error; return data; } catch (error: unknown) { handleNonCriticalError(error, { action: 'Compare entity versions', metadata: { entityType, fromVersionId, toVersionId }, }); if (isMountedRef.current) { toast.error(getErrorMessage(error)); } return null; } }; const rollbackToVersion = async (targetVersionId: string, reason: string) => { try { if (!isMountedRef.current) return null; const { data: userData } = await supabase.auth.getUser(); if (!userData.user) throw new Error('Not authenticated'); const { data, error } = await supabase.rpc('rollback_to_version', { p_entity_type: entityType, p_entity_id: entityId, p_target_version_id: targetVersionId, p_changed_by: userData.user.id, p_reason: reason }); if (error) { // Check for authorization error (insufficient_privilege) if (error.code === '42501') { throw new Error('Only moderators can restore previous versions'); } throw error; } if (isMountedRef.current) { toast.success('Successfully restored to previous version'); await fetchVersions(); } return data; } catch (error: unknown) { handleError(error, { action: 'Rollback entity version', metadata: { entityType, entityId, targetVersionId }, }); if (isMountedRef.current) { toast.error('Failed to restore version', { description: getErrorMessage(error) }); } return null; } }; useEffect(() => { if (entityType && entityId) { fetchVersions(); } }, [entityType, entityId, fetchVersions]); useEffect(() => { if (!entityType || !entityId) return; if (channelRef.current) { try { supabase.removeChannel(channelRef.current); } catch (error: unknown) { handleNonCriticalError(error, { action: 'Cleanup realtime subscription', metadata: { entityType, entityId, context: 'unmount_cleanup' } }); } finally { channelRef.current = null; } } const versionTable = `${entityType}_versions`; const entityIdCol = `${entityType}_id`; const channel = supabase .channel(`${versionTable}_changes`) .on( 'postgres_changes', { event: '*', schema: 'public', table: versionTable, filter: `${entityIdCol}=eq.${entityId}` }, () => { if (isMountedRef.current) { fetchVersions(); } } ) .subscribe(); channelRef.current = channel; return () => { if (channelRef.current) { supabase.removeChannel(channelRef.current).catch((error) => { handleNonCriticalError(error, { action: 'Cleanup realtime subscription', metadata: { entityType, entityId, context: 'unmount_cleanup' } }); }); channelRef.current = null; } }; }, [entityType, entityId, fetchVersions]); useEffect(() => { isMountedRef.current = true; return () => { isMountedRef.current = false; }; }, []); return { versions, currentVersion, loading, fieldHistory, fetchVersions, fetchFieldHistory, compareVersions, rollbackToVersion }; }