developing #10
|
|
@ -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<RemoteCursor[]>([])
|
||||
const realtimeRef = useRef<RealtimeConnection | null>(null)
|
||||
const crdtRef = useRef<CRDTManager | null>(null)
|
||||
const auditRef = useRef<AuditTrailRecorder | null>(null)
|
||||
const isRemoteUpdateRef = useRef(false)
|
||||
const cursorThrottleRef = useRef<number>(0)
|
||||
const [collaborationNotifications, setCollaborationNotifications] = useState<CollaborationNotification[]>([])
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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