piper-utils 1.1.66 → 1.1.68

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "piper-utils",
3
- "version": "1.1.66",
3
+ "version": "1.1.68",
4
4
  "description": "Utility library for Piper",
5
5
  "main": "bin/main.js",
6
6
  "scripts": {
@@ -0,0 +1,436 @@
1
+ import _ from 'lodash';
2
+ import Promise from 'bluebird';
3
+ import { getCurrentUser } from '../requestResponse/requestResponse.js';
4
+ import { accessRightsUtils, getCompanySettings } from '../database/dbUtils/queryStringUtils/accessRightsUtils.js';
5
+
6
+ /**
7
+ * @file Audit logging for Sequelize models. Each parent model gets a sibling
8
+ * `<ModelName>Audit` table that records every field-level change made through
9
+ * Sequelize hooks (afterCreate / afterUpsert / afterUpdate / afterDestroy).
10
+ *
11
+ * Typical wiring in a domain service:
12
+ *
13
+ * 1. At model `dbSetup` (one-time, at boot):
14
+ * `await attachAudit(MyModel);`
15
+ *
16
+ * 2. At the top of each route handler that mutates the model:
17
+ * `bindAuditRequest(event, businessId, [MyModel]);`
18
+ *
19
+ * 3. To list audit history for a record:
20
+ * `const filter = getAuditFilter(MyModel, id, { event, offset, limit });`
21
+ * `const audits = await findAll(getAuditModel(MyModel), filter);`
22
+ *
23
+ * Audit is **off by default** for any model whose request hasn't been bound —
24
+ * so cron lambdas, untouched endpoints, and unit tests will not write audit
25
+ * rows unless they explicitly opt in. The on/off switch comes from the
26
+ * `custom:SET.auditEnabled` JWT claim resolved by {@link getCompanySettings}.
27
+ *
28
+ * The user identity stamped on each row is read from the JWT via
29
+ * {@link getCurrentUser}; `changedByUser` stores `claims.email`. There is
30
+ * intentionally no foreign key back to a User table — domain services must
31
+ * not read across other services' databases.
32
+ */
33
+
34
+ /**
35
+ * Registry of `<ModelName>Audit` Sequelize models, keyed by parent model name.
36
+ * Populated by {@link attachAudit}. Exported for tests; do not mutate from app code.
37
+ *
38
+ * @type {Object<string, import('sequelize').ModelStatic<any>>}
39
+ */
40
+ export const auditModels = {};
41
+
42
+ /**
43
+ * Per-model per-request audit context, keyed by parent model name. Populated by
44
+ * {@link bindAuditRequest} or {@link bindAuditRequestForUser}. A model with no
45
+ * entry here (or with `auditEnabled === false`) will not write any audit rows.
46
+ * Exported for tests; do not mutate from app code.
47
+ *
48
+ * @type {Object<string, { user: { username: string, id?: number }, businessId: string|number, auditEnabled: boolean }>}
49
+ */
50
+ export const modelOptions = {};
51
+
52
+ const SENSITIVE_FIELDS_PATTERN = /"(token|password|secret|key|credential|auth|securityCode|cvv|pin|ssn)":\s*"[^"]*"/gi;
53
+
54
+ function getAuditSchema(model) {
55
+ const DataTypes = model.sequelize.Sequelize;
56
+ return {
57
+ field: { type: DataTypes.STRING, allowNull: false },
58
+ type: DataTypes.STRING,
59
+ valueOld: DataTypes.STRING,
60
+ valueNew: DataTypes.STRING,
61
+ changedByUser: { type: DataTypes.STRING, allowNull: false },
62
+ businessId: { type: DataTypes.STRING, allowNull: false }
63
+ };
64
+ }
65
+
66
+ /**
67
+ * Internal hook factory. Returns the async function that Sequelize invokes on
68
+ * each create/upsert/update/destroy. Reads its per-request state from
69
+ * {@link modelOptions}; emits zero rows when audit is disabled for the model.
70
+ *
71
+ * Sensitive fields inside JSON values (token, password, secret, key,
72
+ * credential, auth, securityCode, cvv, pin, ssn) are masked as
73
+ * `***************` before being persisted. String values longer than 255
74
+ * characters are truncated.
75
+ *
76
+ * Exported only so tests can drive the hook directly. Application code should
77
+ * never call this — use {@link attachAudit}.
78
+ *
79
+ * @param {string} modelName name of the parent Sequelize model
80
+ * @returns {(modelInfo: any, options: { type?: string, transaction?: import('sequelize').Transaction }) => Promise<void>}
81
+ * the hook function Sequelize will call on each lifecycle event
82
+ */
83
+ function auditMe(modelName) {
84
+ return async (modelInfo, options) => {
85
+ if (_.isArray(modelInfo)) {
86
+ modelInfo = modelInfo[0];
87
+ }
88
+
89
+ const opts = modelOptions[modelName];
90
+ if (!opts || !opts.auditEnabled) {
91
+ return;
92
+ }
93
+
94
+ const auditModel = auditModels[modelName];
95
+
96
+ const writeAuditRow = async (field, type, valueOld, valueNew) => {
97
+ const data = {
98
+ field,
99
+ type,
100
+ valueOld: String(valueOld).substring(0, 255),
101
+ valueNew: String(valueNew).substring(0, 255),
102
+ businessId: opts.businessId,
103
+ changedByUser: opts.user.username
104
+ };
105
+ data[modelName + 'Id'] = modelInfo.dataValues.id;
106
+
107
+ const newOptions = {};
108
+ if (options.transaction) {
109
+ newOptions.transaction = options.transaction;
110
+ }
111
+ return auditModel.create(data, newOptions);
112
+ };
113
+
114
+ const checkAudit = async (field, type = 'INITIAL', valueOld, valueNew) => {
115
+ if (_.isEqual(valueOld, valueNew)) {
116
+ return;
117
+ }
118
+ if (_.isString(valueOld) && _.isString(valueNew)) {
119
+ type = 'INSERT';
120
+ }
121
+
122
+ if (_.isArray(valueOld) || _.isArray(valueNew)) {
123
+ let oldString = JSON.stringify(valueOld || []);
124
+ let newString = JSON.stringify(valueNew || []);
125
+ oldString = oldString.replace(SENSITIVE_FIELDS_PATTERN, '"$1":"***************"').substring(0, 255);
126
+ newString = newString.replace(SENSITIVE_FIELDS_PATTERN, '"$1":"***************"').substring(0, 255);
127
+ return writeAuditRow(field, 'UPDATE', oldString, newString);
128
+ }
129
+
130
+ if (_.isObject(valueOld) || _.isObject(valueNew)) {
131
+ _.forEach(valueOld, (valueSub, keySub) => {
132
+ checkAudit(field + ' ' + keySub, 'DELETED', valueSub || '', (valueNew || {})[keySub] || '');
133
+ });
134
+ _.forEach(valueNew, (valueSub, keySub) => {
135
+ checkAudit(field + ' ' + keySub, 'INSERT', (valueOld || {})[keySub] || '', valueSub || '');
136
+ });
137
+ return;
138
+ }
139
+
140
+ return writeAuditRow(field, type, valueOld, valueNew);
141
+ };
142
+
143
+ return Promise.map([...modelInfo._changed], async (field) => {
144
+ const valueOld = modelInfo._previousDataValues[field] || '';
145
+ const valueNew = modelInfo.dataValues[field] || '';
146
+ await checkAudit(field, options.type, valueOld, valueNew);
147
+ });
148
+ };
149
+ }
150
+
151
+ /**
152
+ * Define a sibling `<ModelName>Audit` table for the given Sequelize model and
153
+ * wire the four lifecycle hooks (`afterCreate`, `afterUpsert`, `afterUpdate`,
154
+ * `afterDestroy`) that record changes into it.
155
+ *
156
+ * Call this **once per model at boot time** from inside the model's
157
+ * `dbSetup()` function. It is idempotent — calling it again for a model that
158
+ * already has an audit table attached is a no-op.
159
+ *
160
+ * The audit table has no foreign key into any user table; the username of the
161
+ * person who made the change is stamped onto each row from the JWT (set by
162
+ * {@link bindAuditRequest}). This keeps the audit util portable across domain
163
+ * services that own different user models.
164
+ *
165
+ * @example
166
+ * // src/customer/customer.js
167
+ * Customer.dbSetup = async function () {
168
+ * await attachAudit(Customer);
169
+ * };
170
+ *
171
+ * @param {import('sequelize').ModelStatic<any>} model the Sequelize model to audit
172
+ * @returns {Promise<void>} resolves when the audit table's `sync()` completes
173
+ * @see {@link bindAuditRequest} - per-request setup that turns audit on
174
+ * @see {@link getAuditModel} - retrieve the audit model from the parent
175
+ * @see {@link getAuditFilter} - build a filter for an audit history query
176
+ */
177
+ export async function attachAudit(model) {
178
+ if (auditModels[model.name]) {
179
+ return;
180
+ }
181
+
182
+ const auditModel = model.sequelize.define(model.name + 'Audit', getAuditSchema(model), {
183
+ updatedAt: false,
184
+ freezeTableName: true
185
+ });
186
+ auditModel.belongsTo(model, {
187
+ foreignKey: { allowNull: true },
188
+ onDelete: 'SET NULL'
189
+ });
190
+ auditModels[model.name] = auditModel;
191
+
192
+ model.addHook('afterCreate', auditMe(model.name));
193
+ model.addHook('afterUpsert', auditMe(model.name));
194
+ model.addHook('afterUpdate', auditMe(model.name));
195
+ model.addHook('afterDestroy', auditMe(model.name));
196
+
197
+ return auditModel.sync();
198
+ }
199
+
200
+ /**
201
+ * Bind per-request audit context for one or more models. Call this **once at
202
+ * the top of each route handler** that mutates an audited model.
203
+ *
204
+ * Resolves the acting user from the JWT claims on the event
205
+ * (via {@link getCurrentUser}) and reads the company `auditEnabled` flag from
206
+ * the `custom:SET` claim (via {@link getCompanySettings}). If the company has
207
+ * `auditEnabled: false` then no rows will be written for the bound models on
208
+ * this request.
209
+ *
210
+ * Audit is **off by default** for any model that has not been bound this
211
+ * request — a route that omits this call writes no audit rows.
212
+ *
213
+ * @example
214
+ * // src/customer/customerRoutes.js
215
+ * export async function updateCustomer(event) {
216
+ * checkModule('customer', event);
217
+ * const businessId = checkWriteAccess(event);
218
+ * bindAuditRequest(event, businessId, [Customer]);
219
+ * // ...mutate Customer here; rows are written automatically by hooks
220
+ * }
221
+ *
222
+ * @example
223
+ * // Bind several models in one call when a handler touches more than one
224
+ * bindAuditRequest(event, businessId, [Order, Payment, Receivable]);
225
+ *
226
+ * @param {import('aws-lambda').APIGatewayProxyEvent} event API Gateway event
227
+ * with `requestContext.authorizer.claims` (must include `email` and
228
+ * optionally `custom:UID`, `custom:SET`)
229
+ * @param {string|number} businessId business id this request acts on; stamped
230
+ * onto every audit row written for the bound models
231
+ * @param {Array<import('sequelize').ModelStatic<any>>} models Sequelize models
232
+ * to enable audit on for the duration of this request
233
+ * @returns {void}
234
+ * @see {@link bindAuditRequestForUser} - the equivalent for non-JWT contexts
235
+ * (webhooks, integration crons, Cognito triggers)
236
+ * @see {@link attachAudit} - one-time setup that creates the audit table
237
+ */
238
+ export function bindAuditRequest(event, businessId, models) {
239
+ const user = getCurrentUser(event);
240
+ const auditEnabled = !!(getCompanySettings(event) || {}).auditEnabled;
241
+ _.forEach(models, (model) => {
242
+ // Lazily ensure the audit table model + hooks are wired in this Lambda
243
+ // process. `attachAudit` is idempotent — first call registers, subsequent
244
+ // calls are no-ops. The returned sync() promise is intentionally not
245
+ // awaited; the table is expected to already exist from the migration
246
+ // Lambda, and the synchronous registration is what the request needs.
247
+ const attachPromise = attachAudit(model);
248
+ if (attachPromise && typeof attachPromise.catch === 'function') {
249
+ attachPromise.catch((err) => console.error('attachAudit sync failed for', model.name, err));
250
+ }
251
+ modelOptions[model.name] = { user, businessId, auditEnabled };
252
+ });
253
+ }
254
+
255
+ /**
256
+ * Bind per-request audit context for **system-driven flows** that do not carry
257
+ * an API Gateway JWT — for example, payment-gateway webhooks, integration
258
+ * crons (QuickBooks/Inbox/etc.), Cognito triggers, or batch jobs.
259
+ *
260
+ * Use {@link bindAuditRequest} instead whenever you have a JWT-authenticated
261
+ * API event; that path automatically resolves the user and the company's
262
+ * `auditEnabled` flag from claims.
263
+ *
264
+ * @example
265
+ * // payment webhook from the gateway — no JWT, but we still want audit
266
+ * import { systemUser } from '../user/user.js';
267
+ *
268
+ * export async function webhook(event) {
269
+ * const businessId = event.queryStringParameters.businessId;
270
+ * bindAuditRequestForUser(systemUser, businessId, [Order, Payment]);
271
+ * // ...mutate Order/Payment; audit rows are written by hooks
272
+ * }
273
+ *
274
+ * @example
275
+ * // turn audit OFF explicitly for a system flow that should not be audited
276
+ * bindAuditRequestForUser(systemUser, businessId, [Order], { auditEnabled: false });
277
+ *
278
+ * @param {{ username: string, id?: number }} user identity stamped onto each
279
+ * audit row's `changedByUser` field. Conventionally the `systemUser`
280
+ * constant exported from your service's user model
281
+ * @param {string|number} businessId business id this flow is acting on
282
+ * @param {Array<import('sequelize').ModelStatic<any>>} models Sequelize models
283
+ * to enable audit on
284
+ * @param {{ auditEnabled?: boolean }} [opts] when `auditEnabled` is `false`,
285
+ * binds context but suppresses writes; defaults to `true`
286
+ * @returns {void}
287
+ * @see {@link bindAuditRequest} - the JWT-driven equivalent for API handlers
288
+ */
289
+ export function bindAuditRequestForUser(user, businessId, models, opts = {}) {
290
+ const auditEnabled = opts.auditEnabled !== false;
291
+ _.forEach(models, (model) => {
292
+ // See bindAuditRequest for why this is here.
293
+ const attachPromise = attachAudit(model);
294
+ if (attachPromise && typeof attachPromise.catch === 'function') {
295
+ attachPromise.catch((err) => console.error('attachAudit sync failed for', model.name, err));
296
+ }
297
+ modelOptions[model.name] = { user, businessId, auditEnabled };
298
+ });
299
+ }
300
+
301
+ /**
302
+ * Get the `<ModelName>Audit` Sequelize model that {@link attachAudit} created
303
+ * for a parent model. Returns `undefined` if audit was never attached.
304
+ *
305
+ * @example
306
+ * const audits = await findAll(getAuditModel(Customer), filter);
307
+ *
308
+ * @param {import('sequelize').ModelStatic<any>} model parent Sequelize model
309
+ * @returns {import('sequelize').ModelStatic<any> | undefined} the audit model,
310
+ * or `undefined` if {@link attachAudit} has not been called for this model
311
+ * @see {@link getAuditFilter} - companion filter builder for history queries
312
+ */
313
+ export function getAuditModel(model) {
314
+ return auditModels[model.name];
315
+ }
316
+
317
+ /**
318
+ * Build a Sequelize `findAll`-style filter for an audit-history query, scoped
319
+ * to the businesses the caller is allowed to read (resolved from
320
+ * {@link accessRightsUtils}).
321
+ *
322
+ * The returned filter joins the parent model so callers can render `oldValue`
323
+ * → `newValue` rows in the UI alongside the parent record.
324
+ *
325
+ * @example
326
+ * // GET /customer/{id}/audit
327
+ * export async function getCustomerAudit(event) {
328
+ * bindAuditRequest(event, userDefaultBid(event), [Customer]);
329
+ * const filter = getAuditFilter(Customer, event.pathParameters.id, {
330
+ * event,
331
+ * offset: 0,
332
+ * limit: 10
333
+ * });
334
+ * const audits = await findAll(getAuditModel(Customer), filter);
335
+ * return success(audits);
336
+ * }
337
+ *
338
+ * @param {import('sequelize').ModelStatic<any>} modelToFilter the parent model
339
+ * whose history is being fetched
340
+ * @param {string|number} id parent record id
341
+ * @param {{ event: import('aws-lambda').APIGatewayProxyEvent, offset?: number, limit?: number }} options
342
+ * `event` is required (drives business-id scoping); `offset` and `limit`
343
+ * are pagination passthroughs
344
+ * @returns {{ where: Object, include: Array<{ model: Object }>, order: Array, offset?: number, limit?: number }}
345
+ * a Sequelize `findAll`-compatible filter
346
+ * @see {@link getAuditModel} - call to get the audit model to query against
347
+ */
348
+ export function getAuditFilter(modelToFilter, id, options) {
349
+ const businessIds = accessRightsUtils(options.event);
350
+ const filter = {
351
+ where: {
352
+ businessId: businessIds
353
+ },
354
+ include: [{
355
+ model: modelToFilter
356
+ }],
357
+ order: [
358
+ ['createdAt', 'DESC']
359
+ ]
360
+ };
361
+ filter.where[modelToFilter.name + 'Id'] = id;
362
+
363
+ return { ...filter, ...options };
364
+ }
365
+
366
+ /**
367
+ * Decrement an integer field on a Sequelize instance and write a matching
368
+ * audit row in the same call. Useful for inventory adjustments and other
369
+ * counter-style fields where Sequelize's plain `model.decrement()` would
370
+ * bypass the `afterUpdate` hook.
371
+ *
372
+ * The acting user and businessId come from whatever
373
+ * {@link bindAuditRequest} / {@link bindAuditRequestForUser} was called for
374
+ * this request. If audit is disabled for the model, the decrement still
375
+ * happens but no audit row is written.
376
+ *
377
+ * @example
378
+ * // release inventory on order release
379
+ * await decrementWithAudit('inventory', inventoryEntry, 'quantity', {
380
+ * by: item.quantity,
381
+ * transaction: t
382
+ * });
383
+ *
384
+ * @param {string} modelName name of the parent model (e.g. `'inventory'`)
385
+ * @param {import('sequelize').Model} model the Sequelize instance to mutate
386
+ * @param {string} field the integer/decimal field to decrement
387
+ * @param {{ by?: number, transaction?: import('sequelize').Transaction }} args
388
+ * passed straight through to Sequelize's `decrement()`; transaction is also
389
+ * propagated to the audit write
390
+ * @returns {Promise<import('sequelize').Model>} the updated instance (with
391
+ * `_changed` set on it so internal hooks can observe the change)
392
+ * @see {@link incrementWithAudit} - the +1 counterpart
393
+ */
394
+ export async function decrementWithAudit(modelName, model, field, args) {
395
+ const r = await model.decrement(field, args);
396
+ r._changed = [field];
397
+ const options = {};
398
+ if (args.transaction) {
399
+ options.transaction = args.transaction;
400
+ }
401
+ await auditMe(modelName)(r, options);
402
+ return r;
403
+ }
404
+
405
+ /**
406
+ * Increment an integer field on a Sequelize instance and write a matching
407
+ * audit row in the same call. The mirror of {@link decrementWithAudit}.
408
+ *
409
+ * @example
410
+ * // restock from a receivable
411
+ * await incrementWithAudit('inventory', inventoryEntry, 'quantity', {
412
+ * by: item.quantity,
413
+ * transaction: t
414
+ * });
415
+ *
416
+ * @param {string} modelName name of the parent model (e.g. `'inventory'`)
417
+ * @param {import('sequelize').Model} model the Sequelize instance to mutate
418
+ * @param {string} field the integer/decimal field to increment
419
+ * @param {{ by?: number, transaction?: import('sequelize').Transaction }} args
420
+ * passed straight through to Sequelize's `increment()`; transaction is also
421
+ * propagated to the audit write
422
+ * @returns {Promise<import('sequelize').Model>} the updated instance
423
+ * @see {@link decrementWithAudit} - the -1 counterpart
424
+ */
425
+ export async function incrementWithAudit(modelName, model, field, args) {
426
+ const r = await model.increment(field, args);
427
+ r._changed = [field];
428
+ const options = {};
429
+ if (args.transaction) {
430
+ options.transaction = args.transaction;
431
+ }
432
+ await auditMe(modelName)(r, options);
433
+ return r;
434
+ }
435
+
436
+ export { auditMe };