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