@managemint-solutions/sdk 0.52.0 → 0.53.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -223,20 +223,27 @@ did it, and a `changes` diff of the flat table row as real JSON (`{ field: { old
223
223
  Inserts and hard deletes record a whole-row snapshot instead, so the trail survives the row.
224
224
  Bookkeeping columns are never diffed (`id`, `mms_id`, `organization_id`, `created_at/by`,
225
225
  `user_id`, `updated_at`, `last_updated_by`, `archived_*`, `unarchived_*`, `deleted_*`,
226
- `restored_*`, `entity_type`, `entity_id`); for the tables that hang off another entity (notes,
227
- additional details, supporting files) the `entity_type`/`entity_id` pair becomes the audit row's
228
- `related_entity_*` instead.
226
+ `restored_*`, `entity_type`, `entity_id`); for the tables that hang off another entity the owner
227
+ becomes the audit row's `related_entity_*` instead - the `entity_type`/`entity_id` pair of notes,
228
+ additional details and supporting files, or the foreign key a table declares with `related`
229
+ (timesheet entries to their timesheet, leave adjustments to their entitlement, permissions to
230
+ their user).
231
+
232
+ The trail is read one record at a time. Each resource with a record page exposes its history as
233
+ `audit`: the record's own rows plus every row related to it, newest first.
229
234
 
230
235
  ```ts
231
- const trail = await supabase.audit.list({
232
- page: 1,
233
- page_size: 10,
234
- entity_type: AuditTrailEntityType.CLIENTS,
235
- });
236
- // → MmsList<AuditTrailEntity>, newest first; filter by created_after/before, created_by,
237
- // action, entity_type and entity_id.
236
+ const history = await supabase.clients.audit.list(clientId, { page: 1, page_size: 10 });
237
+ // → MmsList<AuditTrailEntity>. Also on leads, projects, tasks, users, organizations,
238
+ // timesheets, leaveRequests and leaveEntitlements.
239
+
240
+ const names = await supabase.audit.names({ statuses: [statusId], users: [userId] });
241
+ // → { [mms_id]: 'In Progress', ... } - readable names for the ids a page of history holds.
238
242
  ```
239
243
 
244
+ Rows older than a year are deleted by the api's daily retention cron, through the service
245
+ client's `purgeAuditLogsBefore(cutoff)`.
246
+
240
247
  An audit insert **never fails a business write**: if it fails, the error goes to `onAuditError`
241
248
  (default `console.error`) and the write still returns normally.
242
249
 
@@ -1,13 +1,8 @@
1
- import { AuditTrailAction, AuditTrailEntityType } from '@managemint-solutions/entities/audit-trail/enum';
1
+ import { AuditTrailEntityType } from '@managemint-solutions/entities/audit-trail/enum';
2
2
  import type { GetAuditTrailDto as GetAuditTrailDtoType } from '@managemint-solutions/entities/audit-trail/dto';
3
3
  export declare class GetAuditTrailDto implements GetAuditTrailDtoType {
4
- readonly search?: string | null;
5
4
  readonly page: number;
6
5
  readonly page_size: number;
7
- readonly created_after?: string | null;
8
- readonly created_before?: string | null;
9
- readonly created_by: string | null;
10
- readonly action: AuditTrailAction | null;
11
- readonly entity_type: AuditTrailEntityType | null;
12
- readonly entity_id: string | null;
6
+ readonly search?: string | null;
7
+ readonly entity_type?: AuditTrailEntityType | null;
13
8
  }
package/dist/audit/dto.js CHANGED
@@ -15,27 +15,15 @@ const class_transformer_1 = require("class-transformer");
15
15
  const enum_1 = require("@managemint-solutions/entities/audit-trail/enum");
16
16
  const validators_1 = require("../validators");
17
17
  // class-validator version of the shared GetAuditTrailDto type from
18
- // @managemint-solutions/entities, so the wire contract cannot drift.
18
+ // @managemint-solutions/entities, so the wire contract cannot drift. The record itself is the
19
+ // filter; these page, search and narrow its history.
19
20
  class GetAuditTrailDto {
20
- search;
21
21
  page = 1;
22
22
  page_size = 10;
23
- created_after;
24
- created_before;
25
- created_by;
26
- action;
23
+ search;
27
24
  entity_type;
28
- entity_id;
29
25
  }
30
26
  exports.GetAuditTrailDto = GetAuditTrailDto;
31
- __decorate([
32
- (0, class_validator_1.IsOptional)(),
33
- (0, validators_1.IsNullable)(),
34
- (0, class_validator_1.IsString)({ message: 'Search query must be a string' }),
35
- (0, class_validator_1.MinLength)(1, { message: 'Search query cannot be empty' }),
36
- (0, class_validator_1.MaxLength)(255, { message: 'Search query cannot exceed 255 characters' }),
37
- __metadata("design:type", Object)
38
- ], GetAuditTrailDto.prototype, "search", void 0);
39
27
  __decorate([
40
28
  (0, class_validator_1.IsOptional)(),
41
29
  (0, class_transformer_1.Transform)(({ value }) => value ? Number(value) : 1, { toClassOnly: true }),
@@ -54,43 +42,14 @@ __decorate([
54
42
  __decorate([
55
43
  (0, class_validator_1.IsOptional)(),
56
44
  (0, validators_1.IsNullable)(),
57
- (0, class_validator_1.IsDateString)({}, { message: 'Start date must be a valid ISO 8601 date string' }),
58
- __metadata("design:type", Object)
59
- ], GetAuditTrailDto.prototype, "created_after", void 0);
60
- __decorate([
61
- (0, class_validator_1.IsOptional)(),
62
- (0, validators_1.IsNullable)(),
63
- (0, class_validator_1.IsDateString)({}, { message: 'End date must be a valid ISO 8601 date string' }),
64
- __metadata("design:type", Object)
65
- ], GetAuditTrailDto.prototype, "created_before", void 0);
66
- __decorate([
67
- (0, class_validator_1.IsOptional)(),
68
- (0, validators_1.IsNullable)()
69
- // Still v4: `audit_logs.created_by` is a `users.mms_id`, which is the GoTrue auth uid the
70
- // signup trigger copies out of `auth.users.id` - the table's uuidv7 default never fires.
71
- ,
72
- (0, class_validator_1.IsUUID)('4', { message: 'Created by must be a valid UUID v4' }),
73
- __metadata("design:type", Object)
74
- ], GetAuditTrailDto.prototype, "created_by", void 0);
75
- __decorate([
76
- (0, class_validator_1.IsOptional)(),
77
- (0, validators_1.IsNullable)(),
78
- (0, class_validator_1.IsEnum)(enum_1.AuditTrailAction, { message: `Action must be one of: ${Object.values(enum_1.AuditTrailAction).join(', ')}` }),
45
+ (0, class_validator_1.IsString)({ message: 'Search query must be a string' }),
46
+ (0, class_validator_1.MinLength)(1, { message: 'Search query cannot be empty' }),
47
+ (0, class_validator_1.MaxLength)(100, { message: 'Search query cannot exceed 100 characters' }),
79
48
  __metadata("design:type", Object)
80
- ], GetAuditTrailDto.prototype, "action", void 0);
49
+ ], GetAuditTrailDto.prototype, "search", void 0);
81
50
  __decorate([
82
51
  (0, class_validator_1.IsOptional)(),
83
52
  (0, validators_1.IsNullable)(),
84
53
  (0, class_validator_1.IsEnum)(enum_1.AuditTrailEntityType, { message: `Entity type must be one of: ${Object.values(enum_1.AuditTrailEntityType).join(', ')}` }),
85
54
  __metadata("design:type", Object)
86
55
  ], GetAuditTrailDto.prototype, "entity_type", void 0);
87
- __decorate([
88
- (0, class_validator_1.IsOptional)(),
89
- (0, validators_1.IsNullable)()
90
- // Both versions, deliberately: `entity_id` is polymorphic over `entity_type`, so it is a v7
91
- // `mms_id` for every entity type except `users`, whose `mms_id` is the GoTrue auth uid (see
92
- // UserIdDto). Pinning this to one version would make one of those two unfilterable.
93
- ,
94
- (0, class_validator_1.IsUUID)(['4', '7'], { message: 'Entity ID must be a valid UUID' }),
95
- __metadata("design:type", Object)
96
- ], GetAuditTrailDto.prototype, "entity_id", void 0);
@@ -66,9 +66,18 @@ export declare const diffChanges: (before: AuditRow, after: AuditRow) => AuditCh
66
66
  * column that holds something is present, as `'[redacted]'`.
67
67
  */
68
68
  export declare const snapshotChanges: (row: AuditRow, side: "new" | "old") => AuditChanges;
69
+ /** The tables a history's foreign-key values can be named from. */
70
+ export type AuditNameTable = 'users' | 'statuses' | 'clients' | 'projects' | 'tasks' | 'leave_types';
69
71
  /**
70
- * The `audit_logs` table. Reads are the audit trail endpoint; writes go through `record`, which
71
- * every audited write calls for itself - no feature ever writes an audit line by hand.
72
+ * Runs `work` as one event: every audit entry recorded inside it - by any client or service
73
+ * scope - is held, then written once it settles (or throws, since its writes still happened)
74
+ * as **one row per record**. The api wraps each request in it, so a save that writes a task and
75
+ * its labels, or a webhook that moves an organization twice, logs one row, whatever code wrote it.
76
+ */
77
+ export declare const withAuditBatch: <T>(work: () => Promise<T>) => Promise<T>;
78
+ /**
79
+ * The `audit_logs` writer. Every audited write calls `record` for itself - no feature ever
80
+ * writes an audit line by hand. Reads are per record, through `EntityAuditTrail`.
72
81
  */
73
82
  export declare class AuditResource {
74
83
  private readonly supabase;
@@ -76,12 +85,42 @@ export declare class AuditResource {
76
85
  private readonly onError?;
77
86
  constructor(supabase: SupabaseJsClient, actor: AuditActor, onError?: AuditErrorHandler | undefined);
78
87
  /**
79
- * Writes the audit row(s) - one insert, however many entries. **Never throws**: a logging
80
- * problem must not fail the business write, so a failure goes to `onAuditError`
81
- * (default `console.error`) instead.
88
+ * Records the entries: held by the current event's batch when there is one (`withAuditBatch`),
89
+ * written straight away when there is not.
82
90
  */
83
91
  record(entry: AuditEntry | AuditEntry[]): Promise<void>;
84
- list(query: GetAuditTrailDto): Promise<MmsList<AuditTrailEntity>>;
92
+ /**
93
+ * Inserts the audit row(s) - one insert, however many entries. **Never throws**: a logging
94
+ * problem must not fail the business write, so a failure goes to `onAuditError`
95
+ * (default `console.error`) instead. Called by `record`, and by `withAuditBatch` when it
96
+ * writes what it held.
97
+ */
98
+ write(entries: AuditEntry[]): Promise<void>;
99
+ /**
100
+ * Readable names for the ids a page of history holds (`status_id`, `assigned_to_id`, ...),
101
+ * keyed by id: one query per table asked about. An id with no row - the record was hard
102
+ * deleted - is simply absent, and the caller says so.
103
+ */
104
+ names(ids: Partial<Record<AuditNameTable, string[]>>): Promise<Record<string, string>>;
105
+ }
106
+ /**
107
+ * One record's history: the rows about the record itself and every row that hangs off it (a
108
+ * note, a file, a timesheet entry, a user's permissions), newest first. Each resource with a
109
+ * record page exposes one as `audit`, so a client's history is `supabase.clients.audit.list(id)`.
110
+ */
111
+ export declare class EntityAuditTrail {
112
+ private readonly supabase;
113
+ private readonly actor;
114
+ private readonly entityType;
115
+ constructor(supabase: SupabaseJsClient, actor: AuditActor, entityType: AuditTrailEntityType);
116
+ /**
117
+ * A page of the history. `entity_type` keeps the entries about one kind of record (the record
118
+ * itself, or its notes ...). `search` matches what a change recorded - the stored text, field
119
+ * names included - and, because a foreign key is stored as an id, also any change holding the
120
+ * id of a person, status, client, project, task or leave type whose name matches, and any
121
+ * change made by a person whose name matches.
122
+ */
123
+ list(entityId: string, query: GetAuditTrailDto): Promise<MmsList<AuditTrailEntity>>;
85
124
  }
86
125
  export type AuditedTableOptions = {
87
126
  table: string;
@@ -91,6 +130,22 @@ export type AuditedTableOptions = {
91
130
  * table itself, whose own `mms_id` is the organization.
92
131
  */
93
132
  scopeByOrganization?: boolean;
133
+ /**
134
+ * The record a row belongs to, for a table that hangs off one other table by a foreign key
135
+ * (`timesheet_entries.timesheet_id`). Its writes are recorded as related to that record, so
136
+ * they show in the record's history. The polymorphic tables need no option: their own
137
+ * `entity_type`/`entity_id` pair says whose they are.
138
+ */
139
+ related?: {
140
+ entityType: AuditTrailEntityType;
141
+ column: string;
142
+ };
143
+ /**
144
+ * Columns this table's trail never records - machinery whose history is kept elsewhere (an
145
+ * organization's subscription state lives in the billing ledger). A write that changes only
146
+ * these leaves nothing to record, so it writes no audit row at all.
147
+ */
148
+ untracked?: readonly string[];
94
149
  };
95
150
  /** The payload to write, or a function of the pre-read row (`togglePin`'s `!before.pinned`). */
96
151
  type AuditedPayload = AuditRow | ((before: AuditRow) => AuditRow);
@@ -14,7 +14,8 @@ var __exportStar = (this && this.__exportStar) || function(m, exports) {
14
14
  for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p);
15
15
  };
16
16
  Object.defineProperty(exports, "__esModule", { value: true });
17
- exports.auditedTable = exports.AuditResource = exports.snapshotChanges = exports.diffChanges = exports.isRedactedColumn = exports.AUDIT_REDACTED_COLUMNS = exports.AUDIT_EXCLUDED_COLUMNS = exports.withoutInternalId = void 0;
17
+ exports.auditedTable = exports.EntityAuditTrail = exports.AuditResource = exports.withAuditBatch = exports.snapshotChanges = exports.diffChanges = exports.isRedactedColumn = exports.AUDIT_REDACTED_COLUMNS = exports.AUDIT_EXCLUDED_COLUMNS = exports.withoutInternalId = void 0;
18
+ const node_async_hooks_1 = require("node:async_hooks");
18
19
  const enum_1 = require("@managemint-solutions/entities/audit-trail/enum");
19
20
  const errors_1 = require("../errors");
20
21
  const transforms_1 = require("../transforms");
@@ -121,6 +122,34 @@ const snapshotChanges = (row, side) => {
121
122
  return changes;
122
123
  };
123
124
  exports.snapshotChanges = snapshotChanges;
125
+ /**
126
+ * Where each table's readable name comes from (`select`, `name`) and which columns a history
127
+ * search matches it by (`search`). People are read through `user_profiles`, which exposes
128
+ * nothing beyond the name; the view carries no `organization_id`, but it runs as the invoker,
129
+ * so the users RLS keeps it to the caller's tenant.
130
+ */
131
+ const NAME_SOURCES = {
132
+ users: {
133
+ from: 'user_profiles',
134
+ select: 'mms_id, name, surname',
135
+ search: ['name', 'surname'],
136
+ scoped: false,
137
+ name: (row) => [row.name, row.surname].filter(Boolean).join(' '),
138
+ },
139
+ statuses: { from: 'statuses', select: 'mms_id, label', search: ['label'], scoped: true, name: (row) => String(row.label) },
140
+ clients: { from: 'clients', select: 'mms_id, name', search: ['name'], scoped: true, name: (row) => String(row.name) },
141
+ projects: { from: 'projects', select: 'mms_id, name', search: ['name'], scoped: true, name: (row) => String(row.name) },
142
+ tasks: {
143
+ from: 'tasks',
144
+ select: 'mms_id, task_id, name',
145
+ search: ['name'],
146
+ scoped: true,
147
+ name: (row) => `TASK-${row.task_id} ${row.name}`,
148
+ },
149
+ leave_types: { from: 'leave_types', select: 'mms_id, name', search: ['name'], scoped: true, name: (row) => String(row.name) },
150
+ };
151
+ // How many matching names per table a history search widens itself by.
152
+ const SEARCH_NAME_LIMIT = 20;
124
153
  const AUDIT_TRAIL_SELECT = `
125
154
  mms_id,
126
155
  created_at,
@@ -132,9 +161,62 @@ const AUDIT_TRAIL_SELECT = `
132
161
  related_entity_id,
133
162
  changes
134
163
  `;
164
+ /** The batch the current event records into, when there is one - see `withAuditBatch`. */
165
+ const auditBatch = new node_async_hooks_1.AsyncLocalStorage();
166
+ /**
167
+ * Runs `work` as one event: every audit entry recorded inside it - by any client or service
168
+ * scope - is held, then written once it settles (or throws, since its writes still happened)
169
+ * as **one row per record**. The api wraps each request in it, so a save that writes a task and
170
+ * its labels, or a webhook that moves an organization twice, logs one row, whatever code wrote it.
171
+ */
172
+ const withAuditBatch = async (work) => {
173
+ const pending = [];
174
+ try {
175
+ return await auditBatch.run(pending, work);
176
+ }
177
+ finally {
178
+ // Entries about the same record fold together: each field keeps its first `old` and its
179
+ // last `new`. A record created in the event stays created; otherwise a lifecycle move
180
+ // (archive, delete, restore ...) says more than an update. The first writer writes the row.
181
+ const merged = new Map();
182
+ for (const { entry, writer } of pending) {
183
+ const key = `${entry.entityType}:${entry.entityId}`;
184
+ const earlier = merged.get(key);
185
+ if (!earlier) {
186
+ merged.set(key, { entry: { ...entry, changes: { ...entry.changes } }, writer });
187
+ continue;
188
+ }
189
+ for (const [field, change] of Object.entries(entry.changes)) {
190
+ const first = earlier.entry.changes[field];
191
+ earlier.entry.changes[field] = { old: first ? first.old : change.old, new: change.new };
192
+ }
193
+ if (earlier.entry.action !== enum_1.AuditTrailAction.INSERT && entry.action !== enum_1.AuditTrailAction.UPDATE) {
194
+ earlier.entry.action = entry.action;
195
+ }
196
+ earlier.entry.relatedEntityType ??= entry.relatedEntityType;
197
+ earlier.entry.relatedEntityId ??= entry.relatedEntityId;
198
+ }
199
+ const byWriter = new Map();
200
+ for (const { entry, writer } of merged.values()) {
201
+ // A field that ended where it started is no change (a redacted one reads the same on both
202
+ // sides by design, so it stays).
203
+ for (const [field, change] of Object.entries(entry.changes)) {
204
+ if (change.old !== REDACTED && JSON.stringify(change.old) === JSON.stringify(change.new)) {
205
+ delete entry.changes[field];
206
+ }
207
+ }
208
+ // Updates that cancelled out changed nothing: no row.
209
+ if (entry.action === enum_1.AuditTrailAction.UPDATE && Object.keys(entry.changes).length === 0)
210
+ continue;
211
+ byWriter.set(writer, [...(byWriter.get(writer) ?? []), entry]);
212
+ }
213
+ await Promise.all([...byWriter].map(([writer, entries]) => writer.write(entries)));
214
+ }
215
+ };
216
+ exports.withAuditBatch = withAuditBatch;
135
217
  /**
136
- * The `audit_logs` table. Reads are the audit trail endpoint; writes go through `record`, which
137
- * every audited write calls for itself - no feature ever writes an audit line by hand.
218
+ * The `audit_logs` writer. Every audited write calls `record` for itself - no feature ever
219
+ * writes an audit line by hand. Reads are per record, through `EntityAuditTrail`.
138
220
  */
139
221
  class AuditResource {
140
222
  supabase;
@@ -146,14 +228,27 @@ class AuditResource {
146
228
  this.onError = onError;
147
229
  }
148
230
  /**
149
- * Writes the audit row(s) - one insert, however many entries. **Never throws**: a logging
150
- * problem must not fail the business write, so a failure goes to `onAuditError`
151
- * (default `console.error`) instead.
231
+ * Records the entries: held by the current event's batch when there is one (`withAuditBatch`),
232
+ * written straight away when there is not.
152
233
  */
153
234
  async record(entry) {
154
235
  const entries = Array.isArray(entry) ? entry : [entry];
155
236
  if (entries.length === 0)
156
237
  return;
238
+ const batch = auditBatch.getStore();
239
+ if (batch) {
240
+ batch.push(...entries.map((item) => ({ entry: item, writer: this })));
241
+ return;
242
+ }
243
+ await this.write(entries);
244
+ }
245
+ /**
246
+ * Inserts the audit row(s) - one insert, however many entries. **Never throws**: a logging
247
+ * problem must not fail the business write, so a failure goes to `onAuditError`
248
+ * (default `console.error`) instead. Called by `record`, and by `withAuditBatch` when it
249
+ * writes what it held.
250
+ */
251
+ async write(entries) {
157
252
  try {
158
253
  const { error } = await this.supabase.from('audit_logs').insert(entries.map((item) => ({
159
254
  created_by: this.actor.mms_id,
@@ -173,43 +268,104 @@ class AuditResource {
173
268
  handle(error, entries);
174
269
  }
175
270
  }
176
- async list(query) {
271
+ /**
272
+ * Readable names for the ids a page of history holds (`status_id`, `assigned_to_id`, ...),
273
+ * keyed by id: one query per table asked about. An id with no row - the record was hard
274
+ * deleted - is simply absent, and the caller says so.
275
+ */
276
+ async names(ids) {
277
+ const lookups = Object.entries(ids).filter(([, list]) => list.length > 0);
278
+ const pages = await Promise.all(lookups.map(async ([table, list]) => {
279
+ const source = NAME_SOURCES[table];
280
+ // `eq`/`in` return `this`, so the org filter can be reassigned onto the builder.
281
+ let query = this.supabase.from(source.from).select(source.select).in('mms_id', [...new Set(list)]);
282
+ if (source.scoped)
283
+ query = query.eq('organization_id', this.actor.organization_id);
284
+ const { data, error } = await query;
285
+ if (error)
286
+ throw (0, errors_1.mapPostgrestError)(error);
287
+ return (data ?? []).map((row) => [String(row.mms_id), source.name(row)]);
288
+ }));
289
+ return Object.fromEntries(pages.flat());
290
+ }
291
+ }
292
+ exports.AuditResource = AuditResource;
293
+ /**
294
+ * One record's history: the rows about the record itself and every row that hangs off it (a
295
+ * note, a file, a timesheet entry, a user's permissions), newest first. Each resource with a
296
+ * record page exposes one as `audit`, so a client's history is `supabase.clients.audit.list(id)`.
297
+ */
298
+ class EntityAuditTrail {
299
+ supabase;
300
+ actor;
301
+ entityType;
302
+ constructor(supabase, actor, entityType) {
303
+ this.supabase = supabase;
304
+ this.actor = actor;
305
+ this.entityType = entityType;
306
+ }
307
+ /**
308
+ * A page of the history. `entity_type` keeps the entries about one kind of record (the record
309
+ * itself, or its notes ...). `search` matches what a change recorded - the stored text, field
310
+ * names included - and, because a foreign key is stored as an id, also any change holding the
311
+ * id of a person, status, client, project, task or leave type whose name matches, and any
312
+ * change made by a person whose name matches.
313
+ */
314
+ async list(entityId, query) {
177
315
  const page = query.page;
178
316
  const pageSize = query.page_size;
179
317
  const offset = (page - 1) * pageSize;
180
- // `order`/`range` return `this`, so the builder stays a filter builder and the conditional
181
- // filters below can be reassigned onto it without a cast.
182
- let auditTrailQuery = this.supabase
318
+ // `entityId` is interpolated into the filter: callers pass a uuid their route DTO has
319
+ // already validated. The two halves are served by the by-entity and by-related indexes.
320
+ let filter = `or(and(entity_type.eq.${this.entityType},entity_id.eq.${entityId}),` +
321
+ `and(related_entity_type.eq.${this.entityType},related_entity_id.eq.${entityId}))`;
322
+ const search = query.search?.trim();
323
+ if (search) {
324
+ const named = await Promise.all(Object.entries(NAME_SOURCES).map(async ([table, source]) => {
325
+ // `or`/`eq` return `this`, so the org filter can be reassigned onto the builder.
326
+ let lookup = this.supabase
327
+ .from(source.from)
328
+ .select('mms_id')
329
+ .or((0, transforms_1.orIlike)(source.search, search))
330
+ .limit(SEARCH_NAME_LIMIT);
331
+ if (source.scoped)
332
+ lookup = lookup.eq('organization_id', this.actor.organization_id);
333
+ const { data: rows, error: lookupError } = await lookup;
334
+ if (lookupError)
335
+ throw (0, errors_1.mapPostgrestError)(lookupError);
336
+ return { table, ids: (rows ?? []).map((row) => row.mms_id) };
337
+ }));
338
+ const people = named.find((match) => match.table === 'users')?.ids ?? [];
339
+ const matches = [
340
+ `search_text.ilike.${(0, transforms_1.ilikeContains)(search)}`,
341
+ ...named.flatMap((match) => match.ids.map((id) => `search_text.ilike.${(0, transforms_1.ilikeContains)(id)}`)),
342
+ ...(people.length > 0 ? [`created_by.in.(${people.join(',')})`] : []),
343
+ ];
344
+ filter = `and(${filter},or(${matches.join(',')}))`;
345
+ }
346
+ // `eq` returns `this`, so the type filter can be reassigned onto the builder.
347
+ let historyQuery = this.supabase
183
348
  .from('audit_logs')
184
- .select(AUDIT_TRAIL_SELECT, { count: 'estimated' })
349
+ // Exact, not estimated: one record's history is small and index-backed, and the pager
350
+ // must not offer a page that is not there.
351
+ .select(AUDIT_TRAIL_SELECT, { count: 'exact' })
185
352
  .eq('organization_id', this.actor.organization_id)
353
+ // One element, so the record and the search can nest as `and(or(...),or(...))`.
354
+ .or(filter);
355
+ if (query.entity_type)
356
+ historyQuery = historyQuery.eq('entity_type', query.entity_type);
357
+ const { data, error, count } = await historyQuery
186
358
  .order('created_at', { ascending: false })
359
+ // A multi-row insert stamps every row with the same `created_at`; the time-ordered v7
360
+ // `mms_id` keeps the pages stable.
361
+ .order('mms_id', { ascending: false })
187
362
  .range(offset, offset + pageSize - 1);
188
- if (query.created_after) {
189
- auditTrailQuery = auditTrailQuery.gte('created_at', query.created_after);
190
- }
191
- if (query.created_before) {
192
- auditTrailQuery = auditTrailQuery.lte('created_at', query.created_before);
193
- }
194
- if (query.created_by) {
195
- auditTrailQuery = auditTrailQuery.eq('created_by', query.created_by);
196
- }
197
- if (query.action) {
198
- auditTrailQuery = auditTrailQuery.eq('action', query.action);
199
- }
200
- if (query.entity_type) {
201
- auditTrailQuery = auditTrailQuery.eq('entity_type', query.entity_type);
202
- }
203
- if (query.entity_id) {
204
- auditTrailQuery = auditTrailQuery.eq('entity_id', query.entity_id);
205
- }
206
- const { data, error, count } = await auditTrailQuery;
207
363
  if (error)
208
364
  throw (0, errors_1.mapPostgrestError)(error);
209
365
  return (0, transforms_1.toMmsList)(data, page, pageSize, count);
210
366
  }
211
367
  }
212
- exports.AuditResource = AuditResource;
368
+ exports.EntityAuditTrail = EntityAuditTrail;
213
369
  /** The keyed update every audited write builds, before any caller-supplied guard. */
214
370
  const buildUpdate = (supabase, table, id, payload, organizationId) => {
215
371
  // `eq` returns `this`, so the org filter can be reassigned onto the builder without a cast.
@@ -225,12 +381,19 @@ const buildUpdate = (supabase, table, id, payload, organizationId) => {
225
381
  */
226
382
  const auditedTable = (supabase, actor, audit, options) => {
227
383
  const scoped = options.scopeByOrganization !== false;
228
- // Tables that hang off another entity (notes, additional details, supporting files) carry the
229
- // owner in `entity_type`/`entity_id`; the audit row records it as the related entity.
230
- const relatedOf = (row) => ({
231
- relatedEntityType: row.entity_type ?? null,
232
- relatedEntityId: row.entity_id ?? null,
233
- });
384
+ // The record a row belongs to: the declared foreign key, or the owner the polymorphic tables
385
+ // (notes, additional details, supporting files) carry in `entity_type`/`entity_id`. A row with
386
+ // a type and no id (statuses and labels name the kind of record they apply to) has no owner.
387
+ const relatedOf = (row) => {
388
+ const { related } = options;
389
+ const relatedEntityId = row[related?.column ?? 'entity_id'] ?? null;
390
+ if (!relatedEntityId)
391
+ return { relatedEntityType: null, relatedEntityId: null };
392
+ return {
393
+ relatedEntityType: related?.entityType ?? row.entity_type,
394
+ relatedEntityId,
395
+ };
396
+ };
234
397
  const read = async (id) => {
235
398
  // `eq` returns `this`, so the org filter can be reassigned onto the builder without a cast.
236
399
  let query = supabase.from(options.table).select('*').eq('mms_id', id);
@@ -243,13 +406,21 @@ const auditedTable = (supabase, actor, audit, options) => {
243
406
  };
244
407
  const organizationFilter = scoped ? actor.organization_id : null;
245
408
  const updateOf = (id, before, payload) => buildUpdate(supabase, options.table, id, typeof payload === 'function' ? payload(before) : payload, organizationFilter);
246
- const recordWrite = (id, action, before, after) => audit.record({
247
- action,
248
- entityType: options.entityType,
249
- entityId: id,
250
- changes: (0, exports.diffChanges)(before, after),
251
- ...relatedOf(before),
252
- });
409
+ // The changes with the table's untracked columns taken out.
410
+ const tracked = (changes) => Object.fromEntries(Object.entries(changes).filter(([column]) => !options.untracked?.includes(column)));
411
+ // An update that changed nothing tracked is not an event: no row.
412
+ const recordWrite = async (id, action, before, after) => {
413
+ const changes = tracked((0, exports.diffChanges)(before, after));
414
+ if (action === enum_1.AuditTrailAction.UPDATE && Object.keys(changes).length === 0)
415
+ return;
416
+ await audit.record({
417
+ action,
418
+ entityType: options.entityType,
419
+ entityId: id,
420
+ changes,
421
+ ...relatedOf(before),
422
+ });
423
+ };
253
424
  const write = async (id, action, payload) => {
254
425
  const before = await read(id);
255
426
  const { data, error } = await updateOf(id, before, payload).select('*').single();
@@ -290,7 +461,7 @@ const auditedTable = (supabase, actor, audit, options) => {
290
461
  action: enum_1.AuditTrailAction.INSERT,
291
462
  entityType: options.entityType,
292
463
  entityId: row.mms_id,
293
- changes: (0, exports.snapshotChanges)(row, 'new'),
464
+ changes: tracked((0, exports.snapshotChanges)(row, 'new')),
294
465
  ...relatedOf(row),
295
466
  });
296
467
  return (0, exports.withoutInternalId)(row);
@@ -306,7 +477,7 @@ const auditedTable = (supabase, actor, audit, options) => {
306
477
  action: enum_1.AuditTrailAction.INSERT,
307
478
  entityType: options.entityType,
308
479
  entityId: row.mms_id,
309
- changes: (0, exports.snapshotChanges)(row, 'new'),
480
+ changes: tracked((0, exports.snapshotChanges)(row, 'new')),
310
481
  ...relatedOf(row),
311
482
  })));
312
483
  return rows.map(exports.withoutInternalId);
@@ -332,7 +503,7 @@ const auditedTable = (supabase, actor, audit, options) => {
332
503
  action: enum_1.AuditTrailAction.DELETE,
333
504
  entityType: options.entityType,
334
505
  entityId: id,
335
- changes: (0, exports.snapshotChanges)(before, 'old'),
506
+ changes: tracked((0, exports.snapshotChanges)(before, 'old')),
336
507
  ...relatedOf(before),
337
508
  });
338
509
  return (0, exports.withoutInternalId)(before);
@@ -1,7 +1,7 @@
1
1
  import type { ClientEntity, ClientSummary } from '@managemint-solutions/entities/clients';
2
2
  import type { MmsList } from '@managemint-solutions/entities/common';
3
3
  import type { AuthContext } from '../auth';
4
- import { type AuditResource } from '../audit';
4
+ import { EntityAuditTrail, type AuditResource } from '../audit';
5
5
  import type { TypedSupabaseClient } from '../client';
6
6
  import type { CreateClientDto, GetClientsDto, UpdateClientDto } from './dto';
7
7
  export * from './dto';
@@ -9,6 +9,8 @@ export declare class ClientsResource {
9
9
  private readonly supabase;
10
10
  private readonly auth;
11
11
  private readonly table;
12
+ /** This record's history - its own rows and the rows that hang off it. */
13
+ readonly audit: EntityAuditTrail;
12
14
  constructor(supabase: TypedSupabaseClient, auth: AuthContext, audit: AuditResource);
13
15
  list(query: GetClientsDto): Promise<MmsList<ClientSummary>>;
14
16
  getById(clientId: string): Promise<ClientEntity>;
@@ -74,6 +74,8 @@ class ClientsResource {
74
74
  supabase;
75
75
  auth;
76
76
  table;
77
+ /** This record's history - its own rows and the rows that hang off it. */
78
+ audit;
77
79
  constructor(supabase, auth, audit) {
78
80
  this.supabase = supabase;
79
81
  this.auth = auth;
@@ -81,6 +83,7 @@ class ClientsResource {
81
83
  table: 'clients',
82
84
  entityType: enum_1.AuditTrailEntityType.CLIENTS,
83
85
  });
86
+ this.audit = new audit_1.EntityAuditTrail(supabase, auth, enum_1.AuditTrailEntityType.CLIENTS);
84
87
  }
85
88
  async list(query) {
86
89
  const page = query.page;
package/dist/index.d.ts CHANGED
@@ -9,8 +9,8 @@ export type { SupabaseOrganizationScope, SupabaseServiceClientConfig } from './s
9
9
  export { SupabaseClientError } from './errors';
10
10
  export type { SupabaseClientErrorInit } from './errors';
11
11
  export { EmptyToNull, IsNullable } from './validators';
12
- export type { AuditActor, AuditChanges, AuditEntry, AuditErrorHandler, AuditGuard, AuditRow, AuditedTable, AuditedTableOptions, } from './audit';
13
- export { GetAuditTrailDto } from './audit';
12
+ export type { AuditActor, AuditChanges, AuditEntry, AuditErrorHandler, AuditGuard, AuditNameTable, AuditRow, AuditedTable, AuditedTableOptions, EntityAuditTrail, } from './audit';
13
+ export { GetAuditTrailDto, withAuditBatch } from './audit';
14
14
  export { CancelSubscriptionDto, GetPaymentsQueryDto, InitialPaymentDto, PaymentIdDto, PurchaseModuleDto, ReinstateSubscriptionDto, } from './billing';
15
15
  export { GetInvoicesQueryDto, InvoiceIdDto } from './billing/invoices';
16
16
  export type { CreateFeatureRequestRow, CreatedFeatureRequest } from './feature-requests';