diff --git a/README.md b/README.md index fde1590..c7a3158 100644 --- a/README.md +++ b/README.md @@ -53,14 +53,13 @@ Customize the plugin with the following configuration(for example): auditorPlugin({ autoDeleteInterval: '1d', collection: { + changes: { excludeKeys: ['content'] }, trackCollections: [ { slug: 'media', hooks: { afterChange: { - update: { - enabled: true, - }, + update: { enabled: true }, }, }, }, @@ -69,6 +68,8 @@ auditorPlugin({ }), ``` + + ## ✨ **Things you can customize:** - 🔒 **You can control the accessibility of logs.** Due to the increased security of the plugin, other operations are not available. diff --git a/src/collections/auditor.ts b/src/collections/auditor.ts index 06ce199..bc88526 100644 --- a/src/collections/auditor.ts +++ b/src/collections/auditor.ts @@ -4,8 +4,8 @@ import type { AuditHookOperationType } from '../types/pluginOptions.js' import type { hookTypes } from './../pluginUtils/configHelpers.js' import { defaultCollectionValues } from '../Constant/Constant.js' - export type AuditorLog = { + changes?: Record collection: string documentId?: string hook: (typeof hookTypes)[number] @@ -71,6 +71,13 @@ export const auditor: CollectionConfig = { >, required: true, }, + { + name: 'changes', + type: 'json', + admin: { + description: 'Field-level changes or full document snapshot for this operation', + }, + }, { name: 'createdAt', type: 'date', diff --git a/src/core/log-builders/collections/hooks.ts b/src/core/log-builders/collections/hooks.ts index d5f1c0e..e3f6d3c 100644 --- a/src/core/log-builders/collections/hooks.ts +++ b/src/core/log-builders/collections/hooks.ts @@ -28,6 +28,13 @@ import type { } from './../../../types/pluginOptions.js' import type { SharedArgs } from './shared.js' +import { + excludeKeysFromRecord, + normalizeForDiff, + redactKeysInRecord, + redactShallowDiff, + shallowDiff, +} from '../../../utils/diff.js' import { emitWrapper } from './helpers/emitWrapper.js' import { handleDebugMode } from './helpers/handleDebugMode.js' @@ -36,21 +43,95 @@ export const hookHandlers = { args: Parameters[0], sharedArgs: SharedArgs, baseLog: Partial, + data?: { + pluginOpts: PluginOptions + userActivatedHooks?: Partial + userHookConfig?: HookTrackingOperationMap['afterChange'] + userHookOperationConfig?: HookOperationConfig<'afterChange'> + }, ) => { baseLog.type = 'audit' baseLog.documentId = args.doc.id baseLog.user = sharedArgs.req?.user?.id || 'anonymous' + try { + const globalChangeCfg = data?.pluginOpts?.collection?.changes + const opChangeCfg = data?.userHookOperationConfig?.changes + const changesEnabled = opChangeCfg?.enabled ?? globalChangeCfg?.enabled ?? true + if (!changesEnabled) { + return args.doc + } + + const excludeMeta = ['updatedAt', 'createdAt', '__v'] + const excludeKeys = [ + ...excludeMeta, + ...(globalChangeCfg?.excludeKeys ?? []), + ...(opChangeCfg?.excludeKeys ?? []), + ] + const redactKeys = [ + ...(globalChangeCfg?.redactKeys ?? []), + ...(opChangeCfg?.redactKeys ?? []), + ] + + if (args.operation === 'update' && args.previousDoc) { + const next = normalizeForDiff(args.doc as Record) + const prev = normalizeForDiff(args.previousDoc as Record) + let diff = shallowDiff(next, prev, { excludeKeys }) + if (redactKeys.length) { + diff = redactShallowDiff(diff, redactKeys) + } + if (Object.keys(diff).length > 0) { + ;(baseLog as AuditorLog).changes = diff + } + } else if (args.operation === 'create') { + let snap = args.doc as Record + if (excludeKeys.length) {snap = excludeKeysFromRecord(snap, excludeKeys)} + if (redactKeys.length) {snap = redactKeysInRecord(snap, redactKeys) + ;}(baseLog as AuditorLog).changes = snap + } + } catch { + // ignore diff errors to avoid blocking logs + } + return args.doc }, afterDelete: ( args: Parameters[0], sharedArgs: SharedArgs, baseLog: Partial, + data?: { + pluginOpts: PluginOptions + userActivatedHooks?: Partial + userHookConfig?: HookTrackingOperationMap['afterDelete'] + userHookOperationConfig?: HookOperationConfig<'afterDelete'> + }, ) => { baseLog.type = 'audit' baseLog.documentId = args.doc.id baseLog.user = sharedArgs.req?.user?.id || 'anonymous' + try { + const globalChangeCfg = data?.pluginOpts?.collection?.changes + const opChangeCfg = data?.userHookOperationConfig?.changes + const changesEnabled = opChangeCfg?.enabled ?? globalChangeCfg?.enabled ?? true + if (!changesEnabled) {return} + const excludeMeta = ['updatedAt', 'createdAt', '__v'] + const excludeKeys = [ + ...excludeMeta, + ...(globalChangeCfg?.excludeKeys ?? []), + ...(opChangeCfg?.excludeKeys ?? []), + ] + const redactKeys = [ + ...(globalChangeCfg?.redactKeys ?? []), + ...(opChangeCfg?.redactKeys ?? []), + ] + + let snap = args.doc as Record + if (excludeKeys.length) {snap = excludeKeysFromRecord(snap, excludeKeys)} + if (redactKeys.length) {snap = redactKeysInRecord(snap, redactKeys) + ;}(baseLog as AuditorLog).changes = snap + } catch { + // ignore + } }, afterError: ( args: Parameters[0], diff --git a/src/types/pluginOptions.ts b/src/types/pluginOptions.ts index 18ada40..660b24d 100644 --- a/src/types/pluginOptions.ts +++ b/src/types/pluginOptions.ts @@ -186,6 +186,18 @@ export type HookModesConfig = { } export type HookOperationConfig = { + /** + * 📝 Changes capture controls + * + * - enabled: toggle capturing changes for this operation + * - excludeKeys: omit these top-level keys from diffs/snapshots + * - redactKeys: mask these top-level keys in diffs/snapshots + */ + changes?: { + enabled?: boolean + excludeKeys?: string[] + redactKeys?: string[] + } /** * 📝 Custom log creation at a operation level * @@ -213,6 +225,7 @@ export type HookOperationConfig args: Parameters[0], fields: Omit, ) => Omit | Promise> + /** * 📝 Specifies whether logging is enabled or disabled for this operation within the hook * @@ -236,7 +249,6 @@ export type HookOperationConfig * */ enabled?: boolean - /** * 📝 Auxiliary side modes * @@ -1816,6 +1828,18 @@ export type PluginCollectionConfig = { * */ buffer?: BufferConfig + /** + * 📝 Default changes capture controls (overridable per-operation) + * + * - enabled: toggle capturing changes + * - excludeKeys: omit these top-level keys from diffs/snapshots + * - redactKeys: mask these top-level keys in diffs/snapshots + */ + changes?: { + enabled?: boolean + excludeKeys?: string[] + redactKeys?: string[] + } /** * 📝 Collection main configuration * diff --git a/src/utils/diff.ts b/src/utils/diff.ts new file mode 100644 index 0000000..a1bd6fb --- /dev/null +++ b/src/utils/diff.ts @@ -0,0 +1,123 @@ +export type ShallowDiff = Record + +/** + * Compute a shallow diff between two plain objects (top-level keys only). + * Returns a map of keys whose values differ, each with `{ old, new }`. + * + * @param next The updated object. + * @param prev The previous object. + * @param options Optional controls such as `excludeKeys` to skip specific fields. + */ +export const shallowDiff = ( + next: null | Record | undefined, + prev: null | Record | undefined, + options?: { excludeKeys?: string[] }, +): ShallowDiff => { + const out: ShallowDiff = {} + if (!next && !prev) {return out} + const exclude = new Set(options?.excludeKeys ?? []) + + const keys = new Set([ + ...Object.keys((next || {})), + ...Object.keys((prev || {})), + ]) + + for (const key of keys) { + if (exclude.has(key)) {continue} + const a = (prev)?.[key] + const b = (next)?.[key] + if (JSON.stringify(a) !== JSON.stringify(b)) { + out[key] = { new: b, old: a } + } + } + + return out +} + +/** + * Normalize common Payload relationship-like shapes to compact identifiers for diffing. + * - Object with an `id` -> reduce to its `id`. + * - Array of objects with `id` -> array of ids (sorted as strings for stability). + * - Leaves other values as-is (e.g., rich text content trees). + */ +export const normalizeForDiff = ( + obj: null | Record | undefined, +): Record => { + if (!obj) {return {}} + const out: Record = {} + for (const [key, value] of Object.entries(obj)) { + out[key] = reduceValue(value) + } + return out +} + +const isRelObject = (v: unknown): v is { id: unknown } => + !!v && typeof v === 'object' && 'id' in (v as Record) + +const reduceValue = (value: unknown): unknown => { + if (Array.isArray(value)) { + // If array items are relation-like objects, reduce to their ids + if (value.every((it) => isRelObject(it) || typeof it === 'string' || typeof it === 'number')) { + const ids = value.map((it) => (isRelObject(it) ? (it as any).id : it)) + return ids + .map((x) => (typeof x === 'string' || typeof x === 'number' ? String(x) : '')) + .sort() + } + return value + } + + if (isRelObject(value)) { + return (value as any).id + } + + return value +} + +/** + * Remove selected top-level keys from a record. + */ +export const excludeKeysFromRecord = ( + obj: Record, + keys: string[], +): Record => { + const omit = new Set(keys) + return Object.fromEntries( + Object.entries(obj).filter(([k]) => !omit.has(k)), + ) +} + +/** + * Redact selected top-level keys in a record by replacing their values. + */ +export const redactKeysInRecord = ( + obj: Record, + keys: string[], + replacement = '[REDACTED]', +): Record => { + if (!keys?.length) {return obj} + const redact = new Set(keys) + const out: Record = { ...obj } + for (const key of redact) { + if (key in out) {out[key] = replacement} + } + return out +} + +/** + * Redact selected keys in a shallow diff by replacing old/new values. + */ +export const redactShallowDiff = ( + diff: ShallowDiff, + keys: string[], + replacement = '[REDACTED]', +): ShallowDiff => { + if (!keys?.length) {return diff} + const redact = new Set(keys) + const out: ShallowDiff = { ...diff } + for (const key of Object.keys(out)) { + if (redact.has(key)) { + out[key] = { new: replacement, old: replacement } + } + } + return out +}