feat: [US-051] - Audit trail recording

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Gustavo Henrique Santos Souza de Miranda 2026-01-24 02:46:52 -03:00
parent 4a85d7a64b
commit 6ef5cfc7fa
2 changed files with 229 additions and 0 deletions

View File

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

View File

@ -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<typeof setTimeout>
}
const DEBOUNCE_MS = 1000
export class AuditTrailRecorder {
private projectId: string
private userId: string
private previousNodes: Map<string, string> // node ID -> JSON string
private previousEdges: Map<string, string> // edge ID -> JSON string
private pending: Map<string, PendingAuditEntry> // 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<string, string>()
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<string, string>()
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)
}
})
}
}