import { useState, useEffect, useRef, useCallback } from 'react'; import { supabase } from '@/integrations/supabase/client'; import { toast } from 'sonner'; import type { EntityType, EntityVersion } from '@/types/versioning'; 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`; const { data, error } = await supabase .from(versionTable as any) .select(` *, profiles:created_by(username, display_name, avatar_url) `) .eq(entityIdCol, entityId) .order('version_number', { ascending: false }); 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; } const versionsWithProfiles = (data as any[]).map((v: any) => ({ ...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: any) { console.error('Error fetching versions:', error); 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; 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; 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); 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('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: 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; } }; useEffect(() => { if (entityType && entityId) { fetchVersions(); } }, [entityType, entityId, fetchVersions]); useEffect(() => { if (!entityType || !entityId) return; if (channelRef.current) { try { supabase.removeChannel(channelRef.current); } catch (error) { console.error('Error removing previous channel:', error); } 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) => { console.error('Error removing channel:', error); }); channelRef.current = null; } }; }, [entityType, entityId, fetchVersions]); useEffect(() => { isMountedRef.current = true; return () => { isMountedRef.current = false; }; }, []); return { versions, currentVersion, loading, fieldHistory, fetchVersions, fetchFieldHistory, compareVersions, rollbackToVersion }; }