219 lines
6.6 KiB
TypeScript
219 lines
6.6 KiB
TypeScript
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)
|
|
}
|
|
})
|
|
}
|
|
}
|