feat: [US-051] - Audit trail recording
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
4a85d7a64b
commit
6ef5cfc7fa
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue