diff --git a/src/app/editor/[projectId]/FlowchartEditor.tsx b/src/app/editor/[projectId]/FlowchartEditor.tsx index 83657a7..a5bd2b8 100644 --- a/src/app/editor/[projectId]/FlowchartEditor.tsx +++ b/src/app/editor/[projectId]/FlowchartEditor.tsx @@ -37,6 +37,7 @@ import { RealtimeConnection, type ConnectionState, type PresenceUser, type Remot import type { NodeLockInfo } from '@/components/editor/EditorContext' import RemoteCursors from '@/components/editor/RemoteCursors' import { CRDTManager } from '@/lib/collaboration/crdt' +import { AuditTrailRecorder } from '@/lib/collaboration/auditTrail' import ShareModal from '@/components/editor/ShareModal' import CollaborationToast, { type CollaborationNotification } from '@/components/editor/CollaborationToast' import type { FlowchartData, FlowchartNode, FlowchartEdge, Character, Variable, Condition } from '@/types/flowchart' @@ -523,6 +524,7 @@ function FlowchartEditorInner({ projectId, projectName, userId, userDisplayName, const [remoteCursors, setRemoteCursors] = useState([]) const realtimeRef = useRef(null) const crdtRef = useRef(null) + const auditRef = useRef(null) const isRemoteUpdateRef = useRef(false) const cursorThrottleRef = useRef(0) const [collaborationNotifications, setCollaborationNotifications] = useState([]) @@ -568,6 +570,11 @@ function FlowchartEditorInner({ projectId, projectName, userId, userDisplayName, crdtManager.initializeFromData(migratedData.nodes, migratedData.edges) crdtRef.current = crdtManager + // Initialize audit trail recorder + const auditRecorder = new AuditTrailRecorder(projectId, userId) + auditRecorder.initialize(migratedData.nodes, migratedData.edges) + auditRef.current = auditRecorder + const connection = new RealtimeConnection(projectId, userId, userDisplayName, { onConnectionStateChange: setConnectionState, onPresenceSync: setPresenceUsers, @@ -627,6 +634,8 @@ function FlowchartEditorInner({ projectId, projectName, userId, userDisplayName, realtimeRef.current = null crdtManager.destroy() crdtRef.current = null + auditRecorder.destroy() + auditRef.current = null } // eslint-disable-next-line react-hooks/exhaustive-deps }, [projectId, userId, userDisplayName]) @@ -655,11 +664,13 @@ function FlowchartEditorInner({ projectId, projectName, userId, userDisplayName, useEffect(() => { if (isRemoteUpdateRef.current) return crdtRef.current?.updateNodes(nodesForCRDT) + auditRef.current?.recordNodeChanges(nodesForCRDT) }, [nodesForCRDT]) useEffect(() => { if (isRemoteUpdateRef.current) return crdtRef.current?.updateEdges(edgesForCRDT) + auditRef.current?.recordEdgeChanges(edgesForCRDT) }, [edgesForCRDT]) // Broadcast cursor position on mouse move (throttled to 50ms) diff --git a/src/lib/collaboration/auditTrail.ts b/src/lib/collaboration/auditTrail.ts new file mode 100644 index 0000000..f141a30 --- /dev/null +++ b/src/lib/collaboration/auditTrail.ts @@ -0,0 +1,218 @@ +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) + } + }) + } +}