import { useState, useEffect, useRef, useCallback } from 'react'; import { supabase } from '@/integrations/supabase/client'; import { toast } from 'sonner'; interface EntityVersion { id: string; entity_type: string; entity_id: string; version_number: number; version_data: any; changed_by: string; changed_at: string; change_reason: string | null; change_type: 'created' | 'updated' | 'deleted' | 'restored' | 'archived'; submission_id: string | null; is_current: boolean; ip_address_hash: string | null; metadata: any; changer_profile?: { username: string; avatar_url: string | null; }; } interface FieldChange { id: string; field_name: string; old_value: any; new_value: any; change_type: 'added' | 'modified' | 'removed'; created_at: string; } export function useEntityVersions(entityType: string, entityId: string) { const [versions, setVersions] = useState([]); const [currentVersion, setCurrentVersion] = useState(null); const [loading, setLoading] = useState(true); const [fieldHistory, setFieldHistory] = useState([]); // Track if component is mounted to prevent state updates after unmount const isMountedRef = useRef(true); // Track the current channel to prevent duplicate subscriptions const channelRef = useRef | null>(null); // Use a request counter to track the latest fetch and prevent race conditions const requestCounterRef = useRef(0); // Request counter for fetchFieldHistory const fieldHistoryRequestCounterRef = useRef(0); const fetchVersions = useCallback(async () => { if (!isMountedRef.current) return; // Increment counter and capture the current request ID BEFORE try block const currentRequestId = ++requestCounterRef.current; try { // Only set loading if this is still the latest request if (isMountedRef.current && currentRequestId === requestCounterRef.current) { setLoading(true); } const { data, error } = await supabase .from('entity_versions') .select('*') .eq('entity_type', entityType) .eq('entity_id', entityId) .order('version_number', { ascending: false }); if (error) throw error; // Only continue if this is still the latest request and component is mounted if (!isMountedRef.current || currentRequestId !== requestCounterRef.current) return; // Safety check: verify data is an array before processing if (!Array.isArray(data)) { if (isMountedRef.current && currentRequestId === requestCounterRef.current) { setVersions([]); setCurrentVersion(null); setLoading(false); } return; } // Fetch profiles separately const userIds = [...new Set(data.map(v => v.changed_by).filter(Boolean))]; const { data: profiles } = await supabase .from('profiles') .select('user_id, username, avatar_url') .in('user_id', userIds); // Check again if this is still the latest request and component is mounted if (!isMountedRef.current || currentRequestId !== requestCounterRef.current) return; // Safety check: verify profiles array exists before filtering const profilesArray = Array.isArray(profiles) ? profiles : []; const versionsWithProfiles = data.map(v => { const profile = profilesArray.find(p => p.user_id === v.changed_by); return { ...v, changer_profile: profile || { username: 'Unknown', avatar_url: null } }; }) as EntityVersion[]; // Only update state if component is still mounted and this is still the latest request if (isMountedRef.current && currentRequestId === requestCounterRef.current) { setVersions(versionsWithProfiles); setCurrentVersion(versionsWithProfiles.find(v => v.is_current) || null); setLoading(false); } } catch (error: any) { console.error('Error fetching versions:', error); // Use the captured currentRequestId (DO NOT re-read requestCounterRef.current) // Only update state if component is mounted and this is still the latest request if (isMountedRef.current && currentRequestId === requestCounterRef.current) { const errorMessage = error?.message || 'Failed to load version history'; toast.error(errorMessage); setLoading(false); } } }, [entityType, entityId]); const fetchFieldHistory = async (versionId: string) => { if (!isMountedRef.current) return; // Increment counter and capture the current request ID BEFORE try block const currentRequestId = ++fieldHistoryRequestCounterRef.current; try { const { data, error } = await supabase .from('entity_field_history') .select('*') .eq('version_id', versionId) .order('created_at', { ascending: false }); if (error) throw error; // Only update state if component is mounted and this is still the latest request if (isMountedRef.current && currentRequestId === fieldHistoryRequestCounterRef.current) { const fieldChanges = Array.isArray(data) ? data as FieldChange[] : []; setFieldHistory(fieldChanges); } } catch (error: any) { console.error('Error fetching field history:', error); // Use the captured currentRequestId (DO NOT re-read fieldHistoryRequestCounterRef.current) // Only show error if component is mounted and this is still the latest request if (isMountedRef.current && currentRequestId === fieldHistoryRequestCounterRef.current) { const errorMessage = error?.message || 'Failed to load field history'; toast.error(errorMessage); } } }; const compareVersions = async (fromVersionId: string, toVersionId: string) => { try { const { data, error } = await supabase.rpc('compare_versions', { p_from_version_id: fromVersionId, p_to_version_id: toVersionId }); if (error) throw error; return data; } catch (error: any) { console.error('Error comparing versions:', error); if (isMountedRef.current) { const errorMessage = error?.message || 'Failed to compare versions'; toast.error(errorMessage); } 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) throw error; if (isMountedRef.current) { toast.success('Successfully rolled back to previous version'); await fetchVersions(); } return data; } catch (error: any) { console.error('Error rolling back version:', error); if (isMountedRef.current) { const errorMessage = error?.message || 'Failed to rollback version'; toast.error(errorMessage); } return null; } }; const createVersion = async (versionData: any, changeReason?: string, submissionId?: 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('create_entity_version', { p_entity_type: entityType, p_entity_id: entityId, p_version_data: versionData, p_changed_by: userData.user.id, p_change_reason: changeReason || null, p_submission_id: submissionId || null }); if (error) throw error; if (isMountedRef.current) { await fetchVersions(); } return data; } catch (error: any) { console.error('Error creating version:', error); if (isMountedRef.current) { const errorMessage = error?.message || 'Failed to create version'; toast.error(errorMessage); } return null; } }; useEffect(() => { if (entityType && entityId) { fetchVersions(); } }, [entityType, entityId, fetchVersions]); // Set up realtime subscription for version changes useEffect(() => { if (!entityType || !entityId) return; // Clean up existing channel if any if (channelRef.current) { try { supabase.removeChannel(channelRef.current); } catch (error) { console.error('Error removing previous channel:', error); } finally { channelRef.current = null; } } // Create new channel const channel = supabase .channel('entity_versions_changes') .on( 'postgres_changes', { event: '*', schema: 'public', table: 'entity_versions', filter: `entity_type=eq.${entityType},entity_id=eq.${entityId}` }, () => { if (isMountedRef.current) { fetchVersions(); } } ) .subscribe(); channelRef.current = channel; return () => { // Ensure cleanup happens in all scenarios if (channelRef.current) { supabase.removeChannel(channelRef.current).catch((error) => { console.error('Error removing channel:', error); }); channelRef.current = null; } }; }, [entityType, entityId]); // Set mounted ref on mount and cleanup on unmount useEffect(() => { isMountedRef.current = true; return () => { isMountedRef.current = false; }; }, []); return { versions, currentVersion, loading, fieldHistory, fetchVersions, fetchFieldHistory, compareVersions, rollbackToVersion, createVersion }; }