import { createClient } from '@/lib/supabase/client' import type { FlowchartNode, FlowchartEdge } from '@/types/flowchart' export type AuditActionType = | 'node_add' | 'node_update' | 'node_delete' | 'edge_add' | 'edge_update' | 'edge_delete' type PendingAuditEntry = { actionType: AuditActionType entityId: string previousState: unknown | null newState: unknown | null timer: ReturnType } const DEBOUNCE_MS = 1000 export class AuditTrailRecorder { private projectId: string private userId: string private previousNodes: Map // node ID -> JSON string private previousEdges: Map // edge ID -> JSON string private pending: Map // entityId -> pending entry private isDestroyed = false constructor(projectId: string, userId: string) { this.projectId = projectId this.userId = userId this.previousNodes = new Map() this.previousEdges = new Map() this.pending = new Map() } /** Initialize with the current state (no audit entries are created for initial state) */ initialize(nodes: FlowchartNode[], edges: FlowchartEdge[]): void { this.previousNodes.clear() this.previousEdges.clear() nodes.forEach((node) => { this.previousNodes.set(node.id, JSON.stringify(node)) }) edges.forEach((edge) => { this.previousEdges.set(edge.id, JSON.stringify(edge)) }) } /** Record changes by diffing current state against previous state */ recordNodeChanges(currentNodes: FlowchartNode[]): void { if (this.isDestroyed) return const currentMap = new Map() currentNodes.forEach((node) => { currentMap.set(node.id, JSON.stringify(node)) }) // Detect additions and updates for (const [id, serialized] of currentMap) { const previous = this.previousNodes.get(id) if (!previous) { // Node was added this.scheduleWrite(id, 'node_add', null, JSON.parse(serialized)) } else if (previous !== serialized) { // Node was updated this.scheduleWrite(id, 'node_update', JSON.parse(previous), JSON.parse(serialized)) } } // Detect deletions for (const [id, serialized] of this.previousNodes) { if (!currentMap.has(id)) { this.scheduleWrite(id, 'node_delete', JSON.parse(serialized), null) } } // Update previous state this.previousNodes = currentMap } /** Record edge changes by diffing current state against previous state */ recordEdgeChanges(currentEdges: FlowchartEdge[]): void { if (this.isDestroyed) return const currentMap = new Map() currentEdges.forEach((edge) => { currentMap.set(edge.id, JSON.stringify(edge)) }) // Detect additions and updates for (const [id, serialized] of currentMap) { const previous = this.previousEdges.get(id) if (!previous) { // Edge was added this.scheduleWrite(id, 'edge_add', null, JSON.parse(serialized)) } else if (previous !== serialized) { // Edge was updated this.scheduleWrite(id, 'edge_update', JSON.parse(previous), JSON.parse(serialized)) } } // Detect deletions for (const [id, serialized] of this.previousEdges) { if (!currentMap.has(id)) { this.scheduleWrite(id, 'edge_delete', JSON.parse(serialized), null) } } // Update previous state this.previousEdges = currentMap } /** Clean up resources */ destroy(): void { this.isDestroyed = true // Flush all pending writes immediately for (const [entityId, entry] of this.pending) { clearTimeout(entry.timer) this.writeEntry(entry) this.pending.delete(entityId) } } private scheduleWrite( entityId: string, actionType: AuditActionType, previousState: unknown | null, newState: unknown | null ): void { // If there's already a pending write for this entity, cancel it and update const existing = this.pending.get(entityId) if (existing) { clearTimeout(existing.timer) // For debounced updates, keep the original previousState but use latest newState // For type changes (e.g., add then delete within 1s), use the new action type const mergedPreviousState = existing.actionType === 'node_add' || existing.actionType === 'edge_add' ? null // If it was just added, there's no real previous state : existing.previousState const mergedActionType = this.mergeActionTypes(existing.actionType, actionType) // If an entity was added and then deleted within the debounce window, skip entirely if (mergedActionType === null) { this.pending.delete(entityId) return } const entry: PendingAuditEntry = { actionType: mergedActionType, entityId, previousState: mergedPreviousState, newState, timer: setTimeout(() => { this.pending.delete(entityId) this.writeEntry(entry) }, DEBOUNCE_MS), } this.pending.set(entityId, entry) } else { const entry: PendingAuditEntry = { actionType, entityId, previousState, newState, timer: setTimeout(() => { this.pending.delete(entityId) this.writeEntry(entry) }, DEBOUNCE_MS), } this.pending.set(entityId, entry) } } /** Merge two sequential action types for the same entity */ private mergeActionTypes( first: AuditActionType, second: AuditActionType ): AuditActionType | null { // add + delete = no-op (entity never really existed) if ( (first === 'node_add' && second === 'node_delete') || (first === 'edge_add' && second === 'edge_delete') ) { return null } // add + update = still an add (with the updated state) if ( (first === 'node_add' && second === 'node_update') || (first === 'edge_add' && second === 'edge_update') ) { return first } // For all other cases, use the second (latest) action type return second } /** Fire-and-forget write to Supabase */ private writeEntry(entry: PendingAuditEntry): void { if (this.isDestroyed) return const supabase = createClient() supabase .from('audit_trail') .insert({ project_id: this.projectId, user_id: this.userId, action_type: entry.actionType, entity_id: entry.entityId, previous_state: entry.previousState, new_state: entry.newState, }) .then(({ error }) => { if (error) { console.error('[AuditTrail] Failed to write audit entry:', error) } }) } }