@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 +17 -10
- package/dist/audit/dto.d.ts +3 -8
- package/dist/audit/dto.js +7 -48
- package/dist/audit/index.d.ts +61 -6
- package/dist/audit/index.js +218 -47
- package/dist/clients/index.d.ts +3 -1
- package/dist/clients/index.js +3 -0
- package/dist/index.d.ts +2 -2
- package/dist/index.js +4 -3
- package/dist/leads/index.d.ts +3 -2
- package/dist/leads/index.js +3 -2
- package/dist/leave/leave-adjustments.js +1 -0
- package/dist/leave/leave-entitlements.d.ts +3 -1
- package/dist/leave/leave-entitlements.js +3 -0
- package/dist/leave/leave-requests.d.ts +3 -1
- package/dist/leave/leave-requests.js +3 -0
- package/dist/organization-payment-methods/index.d.ts +5 -6
- package/dist/organization-payment-methods/index.js +4 -17
- package/dist/organizations/index.d.ts +3 -1
- package/dist/organizations/index.js +29 -1
- package/dist/permissions/index.js +2 -0
- package/dist/projects/index.d.ts +3 -1
- package/dist/projects/index.js +3 -0
- package/dist/service-client/index.d.ts +7 -0
- package/dist/service-client/index.js +35 -1
- package/dist/tasks/index.d.ts +11 -8
- package/dist/tasks/index.js +16 -12
- package/dist/timesheets/timesheet-entries.js +1 -0
- package/dist/timesheets/timesheets.d.ts +3 -1
- package/dist/timesheets/timesheets.js +3 -0
- package/dist/users/index.d.ts +3 -1
- package/dist/users/index.js +3 -0
- package/package.json +2 -2
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
|
|
227
|
-
|
|
228
|
-
`
|
|
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
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
});
|
|
236
|
-
// →
|
|
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
|
|
package/dist/audit/dto.d.ts
CHANGED
|
@@ -1,13 +1,8 @@
|
|
|
1
|
-
import {
|
|
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
|
|
8
|
-
readonly
|
|
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
|
-
|
|
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.
|
|
58
|
-
|
|
59
|
-
|
|
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, "
|
|
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);
|
package/dist/audit/index.d.ts
CHANGED
|
@@ -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
|
-
*
|
|
71
|
-
*
|
|
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
|
-
*
|
|
80
|
-
*
|
|
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
|
-
|
|
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);
|
package/dist/audit/index.js
CHANGED
|
@@ -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`
|
|
137
|
-
*
|
|
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
|
-
*
|
|
150
|
-
*
|
|
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
|
-
|
|
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
|
-
// `
|
|
181
|
-
//
|
|
182
|
-
let
|
|
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
|
-
|
|
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.
|
|
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
|
-
//
|
|
229
|
-
//
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
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
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
changes
|
|
251
|
-
|
|
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);
|
package/dist/clients/index.d.ts
CHANGED
|
@@ -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>;
|
package/dist/clients/index.js
CHANGED
|
@@ -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';
|