piper-utils 1.1.61 → 1.1.63

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
@@ -1,759 +1,846 @@
1
- # Piper Utils
2
-
3
- Production-ready utilities for building AWS Lambda microservices with Cognito authentication and Sequelize ORM. Simplifies common patterns for API Gateway responses, database queries, access control, and AWS service integrations.
4
-
5
- ## Table of Contents
6
-
7
- - [Overview](#overview)
8
- - [Installation](#installation)
9
- - [Core Concepts](#core-concepts)
10
- - [Real-World Examples](#real-world-examples)
11
- - [Complete Lambda Handler](#complete-lambda-handler)
12
- - [Authentication & Access Control](#authentication--access-control)
13
- - [Database Query Building](#database-query-building)
14
- - [Transaction Management](#transaction-management)
15
- - [Error Handling](#error-handling)
16
- - [Event Manager](#event-manager)
17
- - [watchBucket](#watchbucket)
18
- - [handleFile](#handlefile)
19
- - [handleEvents](#handleevents)
20
- - [publishEvents](#publishevents)
21
- - [API Reference](#api-reference)
22
- - [Testing](#testing)
23
- - [Contributing](#contributing)
24
-
25
- ## Overview
26
-
27
- Piper Utils provides battle-tested utilities extracted from production microservices, focusing on:
28
-
29
- - **Lambda Integration**: Request/response helpers for API Gateway
30
- - **Cognito Auth**: User context extraction and access control
31
- - **Sequelize Helpers**: Query string to ORM translation
32
- - **AWS Services**: S3 and SNS utilities
33
- - **Event Management**: S3 bucket watching and file processing
34
- - **Error Handling**: Consistent error responses
35
- - **Audit Trail**: Automatic change tracking
36
-
37
- ## Installation
38
-
39
- ```bash
40
- npm install piper-utils
41
- ```
42
-
43
- ## Core Concepts
44
-
45
- ### Import Everything You Need
46
-
47
- ```js
48
- import {
49
- // Authentication & Access
50
- accessRightsUtils,
51
- checkModule,
52
- checkWriteAccess,
53
- getCurrentUser,
54
- userDefaultBid,
55
-
56
- // Database Queries
57
- createFilters,
58
- createSort,
59
- createIncludes,
60
- defaultFilters,
61
- findAll,
62
-
63
- // Request/Response
64
- parseBody,
65
- success,
66
- failure,
67
-
68
- // Event Management
69
- watchBucket,
70
- handleFile,
71
- handleEvents,
72
- publishEvents,
73
-
74
- // AWS Services
75
- S3Utils,
76
- SNSUtils
77
- } from 'piper-utils';
78
- ```
79
-
80
- ## Real-World Examples
81
-
82
- ### Complete Lambda Handler
83
-
84
- A production-ready Lambda function with authentication, validation, and database queries:
85
-
86
- ```js
87
- import {
88
- accessRightsUtils,
89
- checkModule,
90
- createFilters,
91
- createSort,
92
- failure,
93
- findAll,
94
- success
95
- } from 'piper-utils';
96
- import Customer from './models/customer';
97
-
98
- export async function getCustomers(event) {
99
- try {
100
- // 1. Check module permissions
101
- checkModule('customer', event);
102
-
103
- // 2. Get user's allowed business IDs from Cognito
104
- const businessIds = accessRightsUtils(event, { useCognitoBid: true });
105
-
106
- // 3. Parse query parameters
107
- const queryParams = event.queryStringParameters || {};
108
-
109
- // 4. Build Sequelize filters from query string
110
- // Example: ?status=Active&createdAt[gte]=2024-01-01&sort=-lastOrderDate&limit=20
111
- const where = createFilters(event, customerFilter);
112
- const order = createSort(event, customerSort);
113
-
114
- // 5. Add access control to query
115
- where.businessId = businessIds;
116
-
117
- // 6. Execute query
118
- const customers = await findAll(Customer, {
119
- where,
120
- order,
121
- limit: parseInt(queryParams.limit || '10'),
122
- offset: parseInt(queryParams.offset || '0')
123
- });
124
-
125
- // 7. Return standardized response
126
- return success(customers);
127
-
128
- } catch (err) {
129
- // Automatic error formatting
130
- return failure(err);
131
- }
132
- }
133
- ```
134
-
135
- ### Authentication & Access Control
136
-
137
- Extract user context and enforce permissions:
138
-
139
- ```js
140
- import {
141
- getCurrentUser,
142
- checkWriteAccess,
143
- accessRightsUtils,
144
- userDefaultBid
145
- } from 'piper-utils';
146
-
147
- export async function updateCustomer(event) {
148
- try {
149
- // Get user from Cognito claims
150
- const user = getCurrentUser(event);
151
- // user = { id, username, email, groups, jwt }
152
-
153
- // Verify write permissions (throws if unauthorized)
154
- const businessId = checkWriteAccess(event);
155
-
156
- // Get all business IDs user can access
157
- const allowedBusinessIds = accessRightsUtils(event, {
158
- useCognitoBid: true
159
- });
160
-
161
- // Get user's default business
162
- const defaultBid = userDefaultBid(event);
163
-
164
- // Parse request body
165
- const updates = parseBody(event);
166
-
167
- // Secure query with access control
168
- const customer = await Customer.findOne({
169
- where: {
170
- id: event.pathParameters.id,
171
- businessId: allowedBusinessIds // Auto-filtered by permissions
172
- }
173
- });
174
-
175
- if (!customer) {
176
- throw { code: 'NOT_FOUND', statusCode: 404 };
177
- }
178
-
179
- // Update with audit trail
180
- customer.set({
181
- ...updates,
182
- updatedBy: user.id,
183
- updatedAt: new Date()
184
- });
185
- await customer.save();
186
-
187
- return success(customer);
188
-
189
- } catch (e) {
190
- return failure(e);
191
- }
192
- }
193
- ```
194
-
195
- ### Database Query Building
196
-
197
- Convert API query strings to Sequelize queries automatically:
198
-
199
- ```js
200
- import {
201
- createFilters,
202
- createSort,
203
- defaultFilters
204
- } from 'piper-utils';
205
- import { Op } from 'sequelize';
206
-
207
- // 1. Define your model schema
208
- const orderSchema = {
209
- orderNumber: { type: db.STRING },
210
- status: { type: db.STRING },
211
- total: { type: db.DECIMAL },
212
- customerId: { type: db.INTEGER },
213
- shippingMethod: { type: db.STRING },
214
- createdAt: { type: db.DATE },
215
- businessId: { type: db.STRING }
216
- };
217
-
218
- // 2. Create filter configuration with relations
219
- export const orderFilter = defaultFilters(orderSchema, {
220
- customer: customerSchema, // Enable customer.* filtering
221
- items: orderItemSchema // Enable items.* filtering
222
- });
223
-
224
- // 3. Use in Lambda handler
225
- export async function searchOrders(event) {
226
- // Query: /orders?status=Shipped&total[gte]=100&customer.email=john@example.com&sort=-createdAt
227
-
228
- const where = createFilters(event, orderFilter);
229
- // Produces: {
230
- // status: 'Shipped',
231
- // total: { [Op.gte]: 100 },
232
- // '$customer.email$': 'john@example.com'
233
- // }
234
-
235
- const order = createSort(event, orderFilter);
236
- // Produces: [['createdAt', 'DESC']]
237
-
238
- const orders = await Order.findAll({
239
- where,
240
- order,
241
- include: [
242
- { model: Customer, as: 'customer' },
243
- { model: OrderItem, as: 'items' }
244
- ]
245
- });
246
-
247
- return success(orders);
248
- }
249
- ```
250
-
251
- ### Transaction Management
252
-
253
- Handle complex operations with automatic rollback:
254
-
255
- ```js
256
- import {
257
- parseBody,
258
- getCurrentUser,
259
- checkWriteAccess,
260
- success,
261
- failure
262
- } from 'piper-utils';
263
- import db from 'sequelize';
264
-
265
- export async function createOrder(event) {
266
- const t = await db.getConnection().transaction();
267
-
268
- try {
269
- const user = getCurrentUser(event);
270
- const businessId = checkWriteAccess(event);
271
- const body = parseBody(event);
272
-
273
- // Validate input
274
- await orderSchema.validateAsync(body);
275
-
276
- // Create order in transaction
277
- const order = await Order.create({
278
- ...body,
279
- businessId,
280
- status: 'Pending',
281
- createdBy: user.id
282
- }, { transaction: t });
283
-
284
- // Create order items
285
- const items = await Promise.all(
286
- body.items.map(item =>
287
- OrderItem.create({
288
- ...item,
289
- orderId: order.id
290
- }, { transaction: t })
291
- )
292
- );
293
-
294
- // Update inventory
295
- for (const item of body.items) {
296
- const inventory = await Inventory.findOne({
297
- where: { partId: item.partId },
298
- transaction: t
299
- });
300
-
301
- if (!inventory || inventory.quantity < item.quantity) {
302
- throw {
303
- code: 'INSUFFICIENT_INVENTORY',
304
- statusCode: 400,
305
- partId: item.partId
306
- };
307
- }
308
-
309
- inventory.quantity -= item.quantity;
310
- await inventory.save({ transaction: t });
311
- }
312
-
313
- // Send notification
314
- await SNSUtils.publish({
315
- topicArn: process.env.ORDER_TOPIC,
316
- message: {
317
- type: 'ORDER_CREATED',
318
- orderId: order.id,
319
- customerId: order.customerId,
320
- total: order.total
321
- }
322
- });
323
-
324
- await t.commit();
325
- return success({ order, items });
326
-
327
- } catch (e) {
328
- await t.rollback();
329
- return failure(e);
330
- }
331
- }
332
- ```
333
-
334
- ### Error Handling
335
-
336
- Consistent error responses with custom codes:
337
-
338
- ```js
339
- import { failure } from 'piper-utils';
340
-
341
- // Define your error catalog
342
- const errorList = {
343
- notFound: {
344
- code: 'NOT_FOUND',
345
- statusCode: 404,
346
- message: 'Resource not found'
347
- },
348
- customerNotFound: {
349
- code: 'CUSTOMER_NOT_FOUND',
350
- statusCode: 404,
351
- message: 'Customer does not exist'
352
- },
353
- insufficientInventory: {
354
- code: 'INSUFFICIENT_INVENTORY',
355
- statusCode: 400,
356
- message: 'Not enough inventory to fulfill order'
357
- },
358
- duplicateEmail: {
359
- code: 'DUPLICATE_EMAIL',
360
- statusCode: 409,
361
- message: 'Email address already exists'
362
- }
363
- };
364
-
365
- export async function createCustomer(event) {
366
- try {
367
- const body = parseBody(event);
368
- const businessId = checkWriteAccess(event);
369
-
370
- // Check for duplicate email
371
- const existing = await Customer.findOne({
372
- where: {
373
- email: body.email,
374
- businessId
375
- }
376
- });
377
-
378
- if (existing) {
379
- throw errorList.duplicateEmail;
380
- }
381
-
382
- // Create customer
383
- const customer = await Customer.create({
384
- ...body,
385
- businessId,
386
- status: 'Active'
387
- });
388
-
389
- return success(customer);
390
-
391
- } catch (e) {
392
- // failure() automatically formats based on error structure
393
- return failure(e);
394
- // Returns: {
395
- // statusCode: 409,
396
- // body: JSON.stringify({
397
- // code: 'DUPLICATE_EMAIL',
398
- // message: 'Email address already exists'
399
- // })
400
- // }
401
- }
402
- }
403
- ```
404
-
405
- ## Event Manager
406
-
407
- The Event Manager module provides utilities for watching S3 buckets and processing file events, perfect for building data ingestion pipelines.
408
-
409
- ### watchBucket
410
-
411
- Monitor an S3 bucket for new files and trigger processing:
412
-
413
- ```js
414
- import { watchBucket } from 'piper-utils';
415
-
416
- // Basic usage - watch for new files
417
- watchBucket({
418
- bucket: process.env.AWS_S3_BUCKET,
419
- onObjectCreated: async (event) => {
420
- console.log('New file detected:', event.s3.object.key);
421
- // Process the file
422
- }
423
- });
424
-
425
- // Lambda function triggered by S3 events
426
- export async function s3EventHandler(event) {
427
- const { handleEvents } = require('piper-utils');
428
-
429
- // Process S3 event records
430
- for (const record of event.Records) {
431
- if (record.eventName.startsWith('ObjectCreated')) {
432
- await handleEvents(record);
433
- }
434
- }
435
-
436
- return { statusCode: 200 };
437
- }
438
- ```
439
-
440
- ### handleFile
441
-
442
- Process individual files from S3:
443
-
444
- ```js
445
- import { handleFile, S3Utils } from 'piper-utils';
446
-
447
- export async function processUploadedFile(bucket, key) {
448
- try {
449
- // handleFile retrieves and processes the file
450
- const result = await handleFile({
451
- bucket,
452
- key,
453
- processor: async (fileContent) => {
454
- // Custom processing logic
455
- const data = JSON.parse(fileContent);
456
-
457
- // Transform data
458
- const transformed = data.map(item => ({
459
- ...item,
460
- processedAt: new Date()
461
- }));
462
-
463
- return transformed;
464
- }
465
- });
466
-
467
- return result;
468
- } catch (e) {
469
- console.error('File processing failed:', e);
470
- throw e;
471
- }
472
- }
473
- ```
474
-
475
- ### handleEvents
476
-
477
- Orchestrate batch event processing:
478
-
479
- ```js
480
- import { handleEvents, success, failure } from 'piper-utils';
481
-
482
- // Lambda handler for batch processing
483
- export async function batchProcessor(event) {
484
- try {
485
- // Process multiple S3 events
486
- const results = await handleEvents(event, {
487
- parallel: true, // Process files in parallel
488
- maxConcurrency: 5,
489
- onError: (error, record) => {
490
- // Custom error handling
491
- console.error(`Failed to process ${record.s3.object.key}:`, error);
492
- }
493
- });
494
-
495
- return success({
496
- processed: results.length,
497
- results
498
- });
499
- } catch (e) {
500
- return failure(e);
501
- }
502
- }
503
- ```
504
-
505
- ### publishEvents
506
-
507
- Publish processed events to SNS:
508
-
509
- ```js
510
- import { publishEvents } from 'piper-utils';
511
-
512
- export async function processAndPublish(events) {
513
- try {
514
- // Process events
515
- const processedEvents = events.map(event => ({
516
- id: event.id,
517
- type: 'FILE_PROCESSED',
518
- timestamp: new Date(),
519
- data: event
520
- }));
521
-
522
- // Publish to SNS
523
- await publishEvents({
524
- topicArn: process.env.EVENT_TOPIC,
525
- events: processedEvents,
526
- batchSize: 10 // Send in batches of 10
527
- });
528
-
529
- return { published: processedEvents.length };
530
- } catch (e) {
531
- console.error('Failed to publish events:', e);
532
- throw e;
533
- }
534
- }
535
- ```
536
-
537
- ### Complete File Ingestion Pipeline Example
538
-
539
- Here's a complete example of a file ingestion pipeline using all event manager utilities:
540
-
541
- ```js
542
- import {
543
- watchBucket,
544
- handleFile,
545
- handleEvents,
546
- publishEvents,
547
- success,
548
- failure
549
- } from 'piper-utils';
550
-
551
- // Lambda function for CSV file ingestion
552
- export async function csvIngestionPipeline(event) {
553
- try {
554
- // 1. Set up bucket watching (for local development)
555
- if (process.env.IS_LOCAL) {
556
- watchBucket({
557
- bucket: process.env.DATA_BUCKET,
558
- prefix: 'uploads/',
559
- suffix: '.csv',
560
- onObjectCreated: async (s3Event) => {
561
- await processCSVFile(s3Event);
562
- }
563
- });
564
- }
565
-
566
- // 2. Handle S3 event (for Lambda)
567
- const results = await handleEvents(event, {
568
- processor: processCSVFile
569
- });
570
-
571
- return success({
572
- processed: results.length,
573
- files: results
574
- });
575
-
576
- } catch (e) {
577
- return failure(e);
578
- }
579
- }
580
-
581
- async function processCSVFile(s3Event) {
582
- const bucket = s3Event.s3.bucket.name;
583
- const key = s3Event.s3.object.key;
584
-
585
- // 3. Process the file
586
- const data = await handleFile({
587
- bucket,
588
- key,
589
- processor: async (content) => {
590
- // Parse CSV
591
- const rows = parseCSV(content);
592
-
593
- // Validate and transform
594
- const validRows = rows.filter(row => validateRow(row));
595
-
596
- // Save to database
597
- await Customer.bulkCreate(validRows);
598
-
599
- return {
600
- total: rows.length,
601
- valid: validRows.length,
602
- invalid: rows.length - validRows.length
603
- };
604
- }
605
- });
606
-
607
- // 4. Publish completion event
608
- await publishEvents({
609
- topicArn: process.env.INGESTION_TOPIC,
610
- events: [{
611
- type: 'CSV_INGESTED',
612
- bucket,
613
- key,
614
- stats: data,
615
- timestamp: new Date()
616
- }]
617
- });
618
-
619
- return data;
620
- }
621
- ```
622
-
623
- ### S3 Event Configuration
624
-
625
- To use these utilities with Lambda, configure your S3 bucket to trigger your Lambda function:
626
-
627
- **Serverless Framework:**
628
- ```yaml
629
- functions:
630
- fileProcessor:
631
- handler: src/handlers/fileProcessor.handler
632
- events:
633
- - s3:
634
- bucket: ${self:custom.dataBucket}
635
- event: s3:ObjectCreated:*
636
- rules:
637
- - prefix: uploads/
638
- - suffix: .csv
639
- ```
640
-
641
- **AWS CDK:**
642
- ```typescript
643
- import * as s3 from '@aws-cdk/aws-s3';
644
- import * as lambda from '@aws-cdk/aws-lambda';
645
- import * as s3n from '@aws-cdk/aws-s3-notifications';
646
-
647
- const bucket = new s3.Bucket(this, 'DataBucket');
648
- const processorFunction = new lambda.Function(this, 'Processor', {
649
- // ... function config
650
- });
651
-
652
- bucket.addEventNotification(
653
- s3.EventType.OBJECT_CREATED,
654
- new s3n.LambdaDestination(processorFunction),
655
- { prefix: 'uploads/', suffix: '.csv' }
656
- );
657
- ```
658
-
659
- ## API Reference
660
-
661
- ### Authentication Functions
662
-
663
- - `getCurrentUser(event)` - Extract user from Cognito claims
664
- - `checkWriteAccess(event)` - Verify write permissions, returns businessId
665
- - `checkModule(moduleName, event)` - Check module access permissions
666
- - `accessRightsUtils(event, options)` - Get user's accessible business IDs
667
- - `userDefaultBid(event)` - Get user's default business ID
668
-
669
- ### Database Functions
670
-
671
- - `createFilters(event, filterConfig)` - Convert query params to WHERE clause
672
- - `createSort(event, sortConfig)` - Convert sort param to ORDER BY
673
- - `createIncludes(event)` - Build include array for relations
674
- - `defaultFilters(schema, relations)` - Create filter configuration
675
- - `findAll(Model, options)` - Execute findAll with options
676
-
677
- ### Request/Response Functions
678
-
679
- - `parseBody(event)` - Parse JSON request body
680
- - `success(data, options)` - Format success response
681
- - `failure(error, options)` - Format error response
682
-
683
- ### Event Manager Functions
684
-
685
- - `watchBucket(options)` - Monitor S3 bucket for new files
686
- - `bucket` - S3 bucket name
687
- - `prefix` - Optional key prefix filter
688
- - `suffix` - Optional key suffix filter
689
- - `onObjectCreated` - Callback for new objects
690
-
691
- - `handleFile(options)` - Process a single file from S3
692
- - `bucket` - S3 bucket name
693
- - `key` - Object key
694
- - `processor` - Async function to process file content
695
-
696
- - `handleEvents(event, options)` - Process batch of S3 events
697
- - `event` - S3 event from Lambda
698
- - `parallel` - Process files in parallel (default: false)
699
- - `maxConcurrency` - Max parallel operations
700
- - `processor` - Custom processor function
701
- - `onError` - Error handler callback
702
-
703
- - `publishEvents(options)` - Publish events to SNS
704
- - `topicArn` - SNS topic ARN
705
- - `events` - Array of events to publish
706
- - `batchSize` - Events per SNS message
707
-
708
- ### Query String Parameters
709
-
710
- Supported operators in query strings:
711
-
712
- - `?field=value` - Exact match
713
- - `?field[gte]=100` - Greater than or equal
714
- - `?field[gt]=100` - Greater than
715
- - `?field[lte]=100` - Less than or equal
716
- - `?field[lt]=100` - Less than
717
- - `?field[ne]=value` - Not equal
718
- - `?field[in]=value1,value2` - In array
719
- - `?field[like]=%pattern%` - SQL LIKE
720
- - `?sort=-field` - Sort descending (no prefix for ascending)
721
- - `?limit=20&offset=40` - Pagination
722
-
723
- ### AWS Service Utilities
724
-
725
- #### S3Utils
726
- ```js
727
- const s3 = new S3Utils();
728
- await s3.getObject({ Bucket, Key });
729
- await s3.putObject({ Bucket, Key, Body });
730
- ```
731
-
732
- #### SNSUtils
733
- ```js
734
- await SNSUtils.publish({
735
- topicArn: 'arn:aws:sns:...',
736
- message: { type: 'EVENT', data: {} },
737
- attributes: { source: 'order-service' }
738
- });
739
- ```
740
-
741
- ## Testing
742
-
743
- Run tests with Jasmine:
744
-
745
- ```bash
746
- npm test
747
- ```
748
-
749
- ## Contributing
750
-
751
- 1. Fork the repository
752
- 2. Create your feature branch
753
- 3. Add tests for new functionality
754
- 4. Ensure all tests pass
755
- 5. Submit a pull request
756
-
757
- ## License
758
-
759
- MIT License - Copyright (c) Piper
1
+ # Piper Utils
2
+
3
+ Utility library for building AWS Lambda microservices with Cognito authentication, Sequelize ORM queries, S3 file processing pipelines, and standardized API Gateway responses.
4
+
5
+ ## Table of Contents
6
+
7
+ - [Installation](#installation)
8
+ - [Exports](#exports)
9
+ - [Request / Response](#request--response)
10
+ - [success](#successbody-options)
11
+ - [successHtml](#successhtmlhtml-options)
12
+ - [failure](#failurebody-options)
13
+ - [parseBody](#parsebodyevent)
14
+ - [parseEvent](#parseeventevent-callback)
15
+ - [getCurrentUser](#getcurrentuserevent)
16
+ - [getCurrentUserNameFromCognitoEvent](#getcurrentusernameFromcognitoeventevent)
17
+ - [Authentication & Access Control](#authentication--access-control)
18
+ - [accessRightsUtils](#accessrightsutiilsevent-options)
19
+ - [checkWriteAccess](#checkwriteaccessevent-options)
20
+ - [checkModule](#checkmodulemodulename-event)
21
+ - [checkIsSuper](#checkissuperevent)
22
+ - [isSuperUser / isSystemUser](#issuperuserevent--issystemuserevent)
23
+ - [isPartnerUser / getBelongsToPartnerId / getEffectivePartnerId](#ispartneruserevent)
24
+ - [enrichEventWithPartnerAccess](#enricheventwithpartneraccessevent-partnerbusinessids-role)
25
+ - [userDefaultBid](#userdefaultbidevent)
26
+ - [getBusinessesInfo](#getbusinessesinfoevent-usecognitobid)
27
+ - [getAccessRightsInfo / getDefaultBusinessIDInfo / getModuleInfo](#low-level-claim-helpers)
28
+ - [getCompanySettings](#getcompanysettingsevent)
29
+ - [JWT Claims Reference](#jwt-claims-reference)
30
+ - [Database Query Helpers](#database-query-helpers)
31
+ - [defaultFilters](#defaultfiltersschema-subschemas)
32
+ - [createFilters](#createfiltersevent-objectfilters)
33
+ - [createSort](#createsortevent-defaultfilter)
34
+ - [createIncludes](#createincludesevent-objectfilters)
35
+ - [findAll](#findallmodel-options)
36
+ - [Query String DSL](#query-string-dsl)
37
+ - [Event Manager (S3 Pipeline)](#event-manager-s3-pipeline)
38
+ - [watchBucket](#watchbucketparams)
39
+ - [handleFile](#handlefilepath-s3bucket-transformer-options)
40
+ - [handleEvents](#handleeventsevent-transformer-errorhandlerperfile-shouldskipfailedfolders-userimporttypes)
41
+ - [publishEvents](#publisheventsconfigtable-tablekey-bucket-snstopic-userimporttypes)
42
+ - [Retry / Error Folder Strategy](#retry--error-folder-strategy)
43
+ - [Database Migrations](#database-migrations)
44
+ - [Built-in Error Codes](#built-in-error-codes)
45
+ - [Examples](#examples)
46
+ - [Peer Dependencies](#peer-dependencies)
47
+ - [Testing](#testing)
48
+
49
+ ## Installation
50
+
51
+ ```bash
52
+ npm install piper-utils
53
+ ```
54
+
55
+ ## Exports
56
+
57
+ Every public function is re-exported from the package root:
58
+
59
+ ```js
60
+ import {
61
+ // Request / Response
62
+ success, successHtml, failure, parseBody, parseEvent,
63
+ getCurrentUser, getCurrentUserNameFromCognitoEvent,
64
+
65
+ // Authentication & Access Control
66
+ accessRightsUtils, checkWriteAccess, checkModule, checkIsSuper,
67
+ isSystemUser, isSuperUser,
68
+ isPartnerUser, getBelongsToPartnerId, getEffectivePartnerId,
69
+ enrichEventWithPartnerAccess,
70
+ userDefaultBid, getBusinessesInfo,
71
+ getAccessRightsInfo, getDefaultBusinessIDInfo, getModuleInfo,
72
+ getCompanySettings,
73
+
74
+ // Database Query Helpers (Sequelize)
75
+ defaultFilters, createFilters, createSort, createIncludes, findAll,
76
+
77
+ // Event Manager (S3 Pipeline)
78
+ watchBucket, handleFile, publishEvents,
79
+
80
+ // Database Migrations
81
+ runMigrations
82
+ } from 'piper-utils';
83
+ ```
84
+
85
+ > **Note:** S3Utils, SNSUtils, and dynamoUtil are internal modules used by the event manager. They are not exported from the package root.
86
+
87
+ ---
88
+
89
+ ## Request / Response
90
+
91
+ Functions for formatting API Gateway Lambda proxy responses with CORS and security headers.
92
+
93
+ All JSON responses include these security headers:
94
+ - `Strict-Transport-Security` (HSTS)
95
+ - `X-Content-Type-Options: nosniff`
96
+ - `X-Frame-Options: DENY`
97
+ - `Content-Security-Policy: default-src 'none'; frame-ancestors 'none'`
98
+ - `Referrer-Policy: strict-origin-when-cross-origin`
99
+ - `Permissions-Policy` (camera, microphone, geolocation disabled)
100
+ - `Cache-Control: no-store`
101
+
102
+ ### `success(body, options?)`
103
+
104
+ Format a 200 JSON response.
105
+
106
+ ```js
107
+ return success({ id: 1, name: 'Widget' });
108
+ // => { statusCode: 200, headers: {...}, body: '{"id":1,"name":"Widget"}' }
109
+ ```
110
+
111
+ | Param | Type | Description |
112
+ |-------|------|-------------|
113
+ | `body` | any | Response data (will be JSON.stringify'd) |
114
+ | `options.dbClose` | function | Optional callback invoked before returning (e.g. close DB connection) |
115
+
116
+ ### `successHtml(html, options?)`
117
+
118
+ Format a 200 HTML response with a relaxed CSP that allows payment provider scripts (TokenEx, NMI, Apple Pay, Sentry).
119
+
120
+ ```js
121
+ return successHtml('<html>...</html>');
122
+ // => { statusCode: 200, headers: { 'Content-Type': 'text/html', ... }, body: '<html>...</html>' }
123
+ ```
124
+
125
+ | Param | Type | Description |
126
+ |-------|------|-------------|
127
+ | `html` | string | Raw HTML content |
128
+ | `options.dbClose` | function | Optional callback invoked before returning |
129
+
130
+ ### `failure(body?, options?)`
131
+
132
+ Format an error response. Automatically detects and normalizes Joi, Sequelize, and Dynamoose errors.
133
+
134
+ ```js
135
+ // Throw a known error
136
+ throw { statusCode: 404, errorCode: '4004', message: 'ITEM NOT FOUND' };
137
+
138
+ // failure() catches and formats it
139
+ return failure(err);
140
+ // => { statusCode: 404, headers: {...}, body: '{"statusCode":404,"errorCode":"4004","message":"ITEM NOT FOUND"}' }
141
+ ```
142
+
143
+ **Auto-detection logic:**
144
+
145
+ | Error type | Detection | Resulting statusCode / errorCode |
146
+ |------------|-----------|----------------------------------|
147
+ | Joi validation | `body.details` exists | 400 / `4000` |
148
+ | Sequelize ForeignKeyConstraint | `body.name === 'SequelizeForeignKeyConstraintError'` | 409 / `4090` |
149
+ | Sequelize UniqueConstraint | `body.name === 'SequelizeUniqueConstraintError'` | 409 / `4091` |
150
+ | Sequelize ValidationError | `body.name === 'SequelizeValidationError'` | 400 / `4001` |
151
+ | Unknown | fallback | 500 / `5XX` |
152
+
153
+ | Param | Type | Description |
154
+ |-------|------|-------------|
155
+ | `body` | object/Error | Error object. If it has `statusCode` and `errorCode`, used as-is. |
156
+ | `options.dbClose` | function | Optional callback invoked before returning |
157
+
158
+ ### `parseBody(event)`
159
+
160
+ Parse the JSON body from an API Gateway Lambda proxy event. Throws `errorList.invalidJson` (400) on malformed JSON.
161
+
162
+ ```js
163
+ const body = parseBody(event);
164
+ // body is the parsed JSON object, or {} if event.body is falsy
165
+ ```
166
+
167
+ Returns the `event.body` as-is if it's already an object (e.g. from serverless-offline).
168
+
169
+ ### `parseEvent(event, callback?)`
170
+
171
+ Like `parseBody` but for parsing an entire event string. Optionally calls `callback(error)` on failure instead of throwing.
172
+
173
+ ```js
174
+ const parsed = parseEvent(eventString);
175
+ ```
176
+
177
+ ### `getCurrentUser(event)`
178
+
179
+ Extract user info from Cognito JWT authorizer claims.
180
+
181
+ ```js
182
+ const user = getCurrentUser(event);
183
+ // => { username: 'john@example.com', id: 42 }
184
+ ```
185
+
186
+ | Return field | Source claim | Default |
187
+ |-------------|-------------|---------|
188
+ | `id` | `custom:UID` (JSON-parsed) | `0` |
189
+ | `username` | `email` | `'localtestuser@gexample.com'` |
190
+
191
+ ### `getCurrentUserNameFromCognitoEvent(event)`
192
+
193
+ Extract email from a **Cognito User Pool trigger event** (different structure than API Gateway authorizer events).
194
+
195
+ ```js
196
+ // Inside a Cognito Pre-Sign-Up or Post-Confirmation trigger
197
+ const email = getCurrentUserNameFromCognitoEvent(event);
198
+ // Reads from event.request.userAttributes['cognito:email_alias'] or ['email']
199
+ ```
200
+
201
+ ---
202
+
203
+ ## Authentication & Access Control
204
+
205
+ All auth functions read JWT claims from `event.requestContext.authorizer.claims` (or `event.requestContext.authorizer` for custom authorizers). See [JWT Claims Reference](#jwt-claims-reference) for the full claim structure.
206
+
207
+ **Local bypass:** When `BUILD_ENV=local`, most auth checks are relaxed to ease development.
208
+
209
+ ### `accessRightsUtils(event, options?)`
210
+
211
+ Get the list of business IDs the current user is authorized to access. Compares requested IDs (from query string or body) against allowed IDs (from JWT claims).
212
+
213
+ ```js
214
+ const businessIds = accessRightsUtils(event, { useCognitoBid: true });
215
+ // => ['1', '5', '12']
216
+ ```
217
+
218
+ | Param | Type | Description |
219
+ |-------|------|-------------|
220
+ | `event` | object | Lambda event |
221
+ | `options.useCognitoBid` | boolean | If true, prefer Cognito businessId; skip local fallback of BID '1' |
222
+
223
+ **Returns:** `string[]` - business IDs user can access.
224
+
225
+ **Behavior by user type:**
226
+ - **Super user:** Gets all requested IDs; if none requested, gets all allowed IDs
227
+ - **System user:** Gets all requested IDs (no filtering)
228
+ - **Regular user:** Gets the intersection of requested and allowed IDs
229
+
230
+ ### `checkWriteAccess(event, options?)`
231
+
232
+ Verify the user has write (`W`) or admin (`A`) role for the businessId in the request body. Throws `errorList.unauthorized` (401) if denied.
233
+
234
+ ```js
235
+ const businessId = checkWriteAccess(event);
236
+ // Returns the authorized businessId string
237
+ ```
238
+
239
+ **Bypassed for:** super users, system users, `BUILD_ENV=local`.
240
+
241
+ ### `checkModule(moduleName, event)`
242
+
243
+ Verify the user has access to a named module. Throws `errorList.unauthorized` (401) if denied.
244
+
245
+ ```js
246
+ checkModule('customer', event); // throws if no access
247
+ checkModule('inventory', event); // throws if no access
248
+ ```
249
+
250
+ Reads module permissions from `custom:MOD` (falls back to `custom:AR`). **Bypassed for:** super users, system users, `BUILD_ENV=local`.
251
+
252
+ ### `checkIsSuper(event)`
253
+
254
+ Throws `errorList.unauthorized` (401) if the user is not a super or system user. **Bypassed for** `BUILD_ENV=local`.
255
+
256
+ ```js
257
+ checkIsSuper(event); // throws if not super/system
258
+ ```
259
+
260
+ ### `isSuperUser(event)` / `isSystemUser(event)`
261
+
262
+ Boolean checks. No throwing, no bypass.
263
+
264
+ ```js
265
+ if (isSuperUser(event)) { /* full access */ }
266
+ if (isSystemUser(event)) { /* machine-to-machine */ }
267
+ ```
268
+
269
+ ### `isPartnerUser(event)`
270
+
271
+ Returns the partner ID from `custom:PID`, or `false`.
272
+
273
+ ### `getBelongsToPartnerId(event)`
274
+
275
+ Returns the partner ID from `custom:BPID` (user's business belongs to a partner), or `false`.
276
+
277
+ ### `getEffectivePartnerId(event)`
278
+
279
+ Returns `isPartnerUser(event) || getBelongsToPartnerId(event)` - the partner ID from either claim, or `false`.
280
+
281
+ ### `enrichEventWithPartnerAccess(event, partnerBusinessIds, role?)`
282
+
283
+ Mutate the event's `custom:AR` claim in-memory to add partner business IDs. Call this **before** `accessRightsUtils()` or `getBusinessesInfo()` so partner admins get access to their partner's merchants.
284
+
285
+ ```js
286
+ // After looking up partner's business IDs from your DB:
287
+ enrichEventWithPartnerAccess(event, ['101', '102', '103'], 'R');
288
+ // Now accessRightsUtils(event) will include 101, 102, 103
289
+ ```
290
+
291
+ | Param | Type | Default | Description |
292
+ |-------|------|---------|-------------|
293
+ | `event` | object | | Lambda event (mutated in-place) |
294
+ | `partnerBusinessIds` | string[] | | Business IDs to add |
295
+ | `role` | string | `'R'` | Access role: `'R'` (read), `'W'` (write), `'A'` (admin) |
296
+
297
+ ### `userDefaultBid(event)`
298
+
299
+ Get the user's default business ID from `custom:DBI`.
300
+
301
+ ```js
302
+ const defaultBid = userDefaultBid(event);
303
+ // => '5' (or '1' as fallback)
304
+ ```
305
+
306
+ ### `getBusinessesInfo(event, useCognitoBid?)`
307
+
308
+ Get the raw business ID to role mapping from `custom:AR`.
309
+
310
+ ```js
311
+ const businesses = getBusinessesInfo(event);
312
+ // => { '1': 'A', '5': 'W', '12': 'R' }
313
+ ```
314
+
315
+ When `BUILD_ENV=local`, automatically injects `{ '1': 'A' }` and any businessId from the request body.
316
+
317
+ ### Low-level Claim Helpers
318
+
319
+ These extract raw claim values without business logic:
320
+
321
+ - **`getAccessRightsInfo(event)`** - Returns `custom:AR.businessIds` as an object (e.g. `{ '1': 'A', '5': 'R' }`)
322
+ - **`getDefaultBusinessIDInfo(event)`** - Returns `custom:DBI.defaultBid` as a string (default `'1'`)
323
+ - **`getModuleInfo(event)`** - Returns `custom:MOD.module` (or `custom:AR.module`) as an object
324
+
325
+ ### `getCompanySettings(event)`
326
+
327
+ Get company-level cached settings from `custom:SET`.
328
+
329
+ ```js
330
+ const settings = getCompanySettings(event);
331
+ // => { auditEnabled: true, ... }
332
+ ```
333
+
334
+ Returns `{}` on parse error.
335
+
336
+ ---
337
+
338
+ ## JWT Claims Reference
339
+
340
+ The library expects these custom Cognito attributes on `event.requestContext.authorizer.claims` (or `event.requestContext.authorizer` for custom authorizers):
341
+
342
+ | Claim | Type | Example | Used by |
343
+ |-------|------|---------|---------|
344
+ | `custom:UID` | JSON number | `"42"` | `getCurrentUser` |
345
+ | `email` | string | `"john@example.com"` | `getCurrentUser` |
346
+ | `custom:SYSTEM` | JSON boolean | `"true"` | `isSystemUser` |
347
+ | `custom:SUPER` | JSON boolean | `"true"` | `isSuperUser` |
348
+ | `custom:AR` | JSON object | `{"businessIds":{"1":"A","5":"R"}}` | `accessRightsUtils`, `checkWriteAccess`, `getBusinessesInfo` |
349
+ | `custom:DBI` | JSON object | `{"defaultBid":"5"}` | `userDefaultBid` |
350
+ | `custom:MOD` | JSON object | `{"module":{"customer":true}}` | `checkModule` |
351
+ | `custom:PID` | string | `"PARTNER_123"` | `isPartnerUser` |
352
+ | `custom:BPID` | string | `"PARTNER_123"` | `getBelongsToPartnerId` |
353
+ | `custom:SET` | JSON object | `{"auditEnabled":true}` | `getCompanySettings` |
354
+
355
+ **Role values** in `custom:AR.businessIds`: `A` (admin), `W` (write), `R` (read).
356
+
357
+ ---
358
+
359
+ ## Database Query Helpers
360
+
361
+ Utilities for translating API query string parameters into Sequelize queries. These handle filtering, sorting, pagination, and relation includes automatically.
362
+
363
+ ### `defaultFilters(schema, subSchemas?)`
364
+
365
+ Create a filter configuration object from a Sequelize model schema. This maps each column's data type to the appropriate Sequelize operator.
366
+
367
+ ```js
368
+ import DB from 'sequelize';
369
+
370
+ const orderSchema = {
371
+ orderNumber: { type: new DB.STRING() },
372
+ status: { type: new DB.STRING() },
373
+ total: { type: new DB.DECIMAL() },
374
+ active: { type: new DB.BOOLEAN() },
375
+ metadata: { type: DB.JSONB },
376
+ createdAt: { type: new DB.DATE() }
377
+ };
378
+
379
+ const orderFilter = defaultFilters(orderSchema, {
380
+ customer: customerSchema // enable customer.* filtering
381
+ });
382
+ ```
383
+
384
+ **Type-to-operator mapping:**
385
+
386
+ | Sequelize type | Operator | Behavior |
387
+ |---------------|----------|----------|
388
+ | STRING, CHAR, TEXT | `Op.iLike` | Case-insensitive `LOWER(col) LIKE '%value%'` |
389
+ | INTEGER, DECIMAL, BIGINT | `Op.or` | Matches `+value` or `-value` |
390
+ | BOOLEAN | `Op.eq` | Exact match with string-to-boolean coercion |
391
+ | JSONB | (special) | Case-insensitive search via `jsonb_extract_path_text` |
392
+ | DATE | `Op.between` | Range filter |
393
+ | DATEONLY | `Op.eq` | Exact match |
394
+
395
+ **Auto-added fields:** `id`, `createdAt`, `updatedAt` are always included.
396
+
397
+ ### `createFilters(event, objectFilters)`
398
+
399
+ Convert query string parameters into a Sequelize WHERE clause. Automatically applies:
400
+ - **Business ID scoping** via `accessRightsUtils(event)`
401
+ - **Active record filtering** (defaults `active: true` if the schema has an `active` column)
402
+ - **Date range filtering** when both `startDate` and `endDate` are provided
403
+ - **Full-text search** when `searchString` is provided
404
+
405
+ ```js
406
+ // URL: /orders?status=Shipped&searchString=john&startDate=2024-01-01&endDate=2024-12-31&sort=-createdAt
407
+ const where = createFilters(event, orderFilter);
408
+ ```
409
+
410
+ See [Query String DSL](#query-string-dsl) for all supported operators.
411
+
412
+ ### `createSort(event, defaultFilter)`
413
+
414
+ Convert the `sort` query parameter into a Sequelize ORDER BY array.
415
+
416
+ ```js
417
+ // URL: /orders?sort=-createdAt,status
418
+ const order = createSort(event, orderFilter);
419
+ // => [['createdAt', 'DESC'], ['status', 'ASC']]
420
+ ```
421
+
422
+ - Prefix with `-` for DESC, no prefix for ASC
423
+ - Comma-separated for multiple fields
424
+ - **Default:** `-updatedAt` (DESC)
425
+ - Only allows sorting on fields defined in `defaultFilter` (prevents injection)
426
+
427
+ ### `createIncludes(event, objectFilters)`
428
+
429
+ Build a Sequelize includes array by detecting which relations are referenced in filter or sort parameters (via dot-notation).
430
+
431
+ ```js
432
+ // URL: /orders?customer.email=john@test.com&sort=-customer.name
433
+ const includes = createIncludes(event, orderFilter);
434
+ // => ['customer']
435
+ ```
436
+
437
+ ### `findAll(model, options)`
438
+
439
+ Paginated wrapper around `Model.findAll()`. Fetches `limit + 1` records to detect `hasMore` without a COUNT query.
440
+
441
+ ```js
442
+ const result = await findAll(Order, {
443
+ where,
444
+ order,
445
+ limit: 20,
446
+ offset: 0,
447
+ includes: [{ model: Customer, as: 'customer' }]
448
+ });
449
+ // => { offset: 0, limit: 20, rows: [...], hasMore: true }
450
+ ```
451
+
452
+ | Param | Type | Default | Description |
453
+ |-------|------|---------|-------------|
454
+ | `model` | Sequelize.Model | | Sequelize model class |
455
+ | `options.where` | object | | WHERE clause (from `createFilters`) |
456
+ | `options.order` | array | | ORDER BY (from `createSort`) |
457
+ | `options.includes` | array | `{ all: true, nested: true }` | Relations to include |
458
+ | `options.limit` | number | `0` (no limit) | Page size |
459
+ | `options.offset` | number | `0` | Page offset |
460
+
461
+ **Returns:** `{ offset, limit, rows, hasMore }`
462
+
463
+ ---
464
+
465
+ ## Query String DSL
466
+
467
+ Supported query string parameters for `createFilters` and `createSort`:
468
+
469
+ | Query | Behavior |
470
+ |-------|----------|
471
+ | `?field=value` | Match by type: iLike for strings, eq for booleans, or for numbers |
472
+ | `?searchString=john` | OR search across all filterable string/numeric fields |
473
+ | `?startDate=2024-01-01&endDate=2024-12-31` | BETWEEN on `createdAt` |
474
+ | `?sort=-field` | Sort DESC (prefix `-`) |
475
+ | `?sort=field1,-field2` | Multi-field sort, comma-separated |
476
+ | `?token.cardHolderName=gregory` | JSONB dot-notation search (case-insensitive) |
477
+ | `?token={"cardType":"visa"}` | JSONB object search (all key-value pairs, case-insensitive) |
478
+ | `?customer.email=john` | Relation field filter (auto-includes the relation) |
479
+
480
+ ---
481
+
482
+ ## Event Manager (S3 Pipeline)
483
+
484
+ A set of utilities for building S3 file processing pipelines. The typical flow is:
485
+
486
+ 1. **Cron job** triggers `watchBucket` -> calls `publishEvents` to scan S3 and publish file batches to SNS
487
+ 2. **SNS trigger** invokes Lambda -> `watchBucket` routes to `handleEvents` which processes files in parallel
488
+ 3. **Direct S3 trigger** invokes Lambda -> `watchBucket` routes to `handleDirectS3WriteEvent`
489
+
490
+ ### `watchBucket(params)`
491
+
492
+ Entry point for the S3 pipeline. Routes incoming events to the appropriate handler based on event source.
493
+
494
+ ```js
495
+ import { watchBucket } from 'piper-utils';
496
+
497
+ export async function handler(event) {
498
+ return watchBucket({
499
+ event,
500
+ dynamoConfigTable: 'Config-Dev',
501
+ dynamoConfigKey: 'importConfig',
502
+ s3Bucket: 'my-data-bucket',
503
+ snsTopic: 'my-import-topic',
504
+ transformer: async (parsedJson) => {
505
+ // Process each file's parsed JSON content
506
+ await saveToDatabase(parsedJson);
507
+ },
508
+ errorHandlerPerFile: (err, filePath) => {
509
+ console.error(`Failed: ${filePath}`, err);
510
+ return false; // return true to suppress error, false to fail
511
+ },
512
+ shouldSkipFailedFolders: false,
513
+ userImportTypes: { orders: 'orders', customers: 'customers' }
514
+ });
515
+ }
516
+ ```
517
+
518
+ | Param | Type | Required | Description |
519
+ |-------|------|----------|-------------|
520
+ | `event` | object | Yes | Lambda event (SNS, S3, or CloudWatch cron) |
521
+ | `dynamoConfigTable` | string | Yes | DynamoDB table name for pipeline config |
522
+ | `dynamoConfigKey` | string | Yes | Key in DynamoDB table (holds `snsChunkSize` and `snsMaxMessages`) |
523
+ | `s3Bucket` | string | Yes | S3 bucket name to watch |
524
+ | `snsTopic` | string | Yes | SNS topic name for publishing file batches |
525
+ | `transformer` | function | Yes | `async (parsedJson) => result` - processes each file's content |
526
+ | `errorHandlerPerFile` | function | No | `(error, filePath) => boolean` - return `true` to suppress, `false` to fail batch |
527
+ | `shouldSkipFailedFolders` | boolean | No | If `true`, skip retry folders and move directly to `error/` |
528
+ | `userImportTypes` | object | No | Map of import type subdirectories |
529
+
530
+ **Routing logic:**
531
+ - `EventSource === 'aws:sns'` -> `handleEvents()`
532
+ - `EventSource === 'aws:s3'` -> `handleDirectS3WriteEvent()` (skips files in `failed-once/`, `failed-twice/`, `error/`)
533
+ - `EventSource === 'aws:s3'` with key prefix `DIRECT` -> `handleDirectS3WriteEvent()`
534
+ - Otherwise (cron job, no Records) -> `publishEvents()`
535
+
536
+ ### `handleFile(path, s3Bucket, transformer, options?)`
537
+
538
+ Process a single file from S3. Downloads the file, parses it as JSON, passes it to the transformer, then deletes the original. On error, moves the file through the retry folder strategy.
539
+
540
+ ```js
541
+ import { handleFile } from 'piper-utils';
542
+
543
+ const result = await handleFile(
544
+ 'orders/order-123.json',
545
+ 'my-data-bucket',
546
+ async (parsedJson) => {
547
+ // parsedJson is the JSON.parse'd file content
548
+ await Order.create(parsedJson);
549
+ return { processed: true };
550
+ },
551
+ { shouldSkipFailedFolders: false }
552
+ );
553
+ ```
554
+
555
+ | Param | Type | Description |
556
+ |-------|------|-------------|
557
+ | `path` | string | S3 object key |
558
+ | `s3Bucket` | string | S3 bucket name |
559
+ | `transformer` | function | `async (parsedJson) => result` |
560
+ | `options.shouldSkipFailedFolders` | boolean | Skip retry folders, move straight to `error/` |
561
+ | `options.userImportTypes` | object | Import type subdirectories |
562
+
563
+ **Returns:** The transformer's return value, or `undefined` if the file was empty/missing.
564
+
565
+ **Behavior:**
566
+ - Returns silently if file not found (`NoSuchKey`)
567
+ - Returns silently (and deletes file) if body is empty, `{}`, or `[]`
568
+ - On success: deletes the original file
569
+ - On error: moves file through retry folders, then re-throws
570
+
571
+ ### `handleEvents(event, transformer, errorHandlerPerFile?, shouldSkipFailedFolders?, userImportTypes?)`
572
+
573
+ Process a batch of files from SNS-relayed S3 events. Processes files in parallel using `Bluebird.Promise.map`.
574
+
575
+ ```js
576
+ // event.Records[].Sns.Message = JSON.stringify({ bucket: '...', files: ['file1.json', 'file2.json'] })
577
+ await handleEvents(event, transformer, errorHandlerPerFile, false, userImportTypes);
578
+ ```
579
+
580
+ | Param | Type | Description |
581
+ |-------|------|-------------|
582
+ | `event` | object | SNS event with `Records[].Sns.Message` containing `{ bucket, files }` |
583
+ | `transformer` | function | `async (parsedJson) => result` |
584
+ | `errorHandlerPerFile` | function | `(error, filePath) => boolean` - return `true` to suppress |
585
+ | `shouldSkipFailedFolders` | boolean | Default `false` |
586
+ | `userImportTypes` | object | Import subdirectories |
587
+
588
+ Throws `'ERROR: HANDLE-FILE'` if any file fails and `errorHandlerPerFile` returns `false` (or is not provided).
589
+
590
+ ### `publishEvents(configTable, tableKey, bucket, snsTopic, userImportTypes?)`
591
+
592
+ Scan an S3 bucket and publish file batches to SNS. Typically called by a cron job via `watchBucket`.
593
+
594
+ ```js
595
+ await publishEvents('Config-Dev', 'importConfig', 'my-data-bucket', 'my-import-topic');
596
+ ```
597
+
598
+ | Param | Type | Description |
599
+ |-------|------|-------------|
600
+ | `configTable` | string | DynamoDB table with pipeline config |
601
+ | `tableKey` | string | Config key (item must have `snsChunkSize` and `snsMaxMessages`) |
602
+ | `bucket` | string | S3 bucket to scan |
603
+ | `snsTopic` | string | SNS topic name |
604
+ | `userImportTypes` | object | Import subdirectories (optional) |
605
+
606
+ **Config lookup:** Reads `Item.snsChunkSize` (default: 2) and `Item.snsMaxMessages` (default: 10) from DynamoDB.
607
+
608
+ **File filtering:** Skips files in `error/`, `failed-once/`, `failed-twice/`. Supports `delayUntil/` folder (files processed only after the time encoded in the path, format `YYYYMMDDHHmm`).
609
+
610
+ **Publishing:** Lists all eligible files, chunks them by `snsChunkSize`, publishes up to `snsMaxMessages` SNS messages. Each message body: `{ bucket, files: [...keys] }`.
611
+
612
+ ### Retry / Error Folder Strategy
613
+
614
+ When `handleFile` encounters an error processing a file, it moves the file through progressive retry folders before giving up:
615
+
616
+ ```
617
+ file.json --(error)--> failed-once/file.json
618
+ --(error)--> failed-twice/file.json
619
+ --(error)--> error/file.json (terminal)
620
+ ```
621
+
622
+ If `shouldSkipFailedFolders: true`, files go directly to `error/` on first failure.
623
+
624
+ With `userImportTypes`, the same pattern applies within each import type's subfolder:
625
+ ```
626
+ orders/file.json --> orders/failed-once/file.json --> orders/failed-twice/file.json --> orders/error/file.json
627
+ ```
628
+
629
+ Files in `error/` are never reprocessed. A nesting guard prevents double-nesting (e.g. `failed-once/failed-once/file.json`).
630
+
631
+ ---
632
+
633
+ ## Database Migrations
634
+
635
+ ### `runMigrations(databaseName, sequelizeInstance, initializeModels, pathToMigrationFolder?)`
636
+
637
+ Execute pending database migrations using [Umzug](https://github.com/sequelize/umzug). On failure, rolls back pending migrations before throwing.
638
+
639
+ ```js
640
+ import { runMigrations } from 'piper-utils';
641
+
642
+ await runMigrations(
643
+ 'my-database',
644
+ sequelizeInstance,
645
+ async () => { await sequelizeInstance.sync(); },
646
+ `${__dirname}/migrations/*.js` // optional, defaults to ${PWD}/migrations/*.js
647
+ );
648
+ ```
649
+
650
+ | Param | Type | Default | Description |
651
+ |-------|------|---------|-------------|
652
+ | `databaseName` | string | | Database name (for logging) |
653
+ | `sequelizeInstance` | Sequelize | | Initialized Sequelize instance |
654
+ | `initializeModels` | function | | Async function to define/sync models (called after migrations) |
655
+ | `pathToMigrationFolder` | string | `${PWD}/migrations/*.js` | Glob pattern for migration files |
656
+
657
+ ---
658
+
659
+ ## Built-in Error Codes
660
+
661
+ The library includes a pre-defined `errorList` used internally by auth checks and `parseBody`. These are the error objects that get thrown/returned:
662
+
663
+ | Key | errorCode | statusCode | Message |
664
+ |-----|-----------|------------|---------|
665
+ | `unauthorized` | 4111 | 401 | UNAUTHORIZED |
666
+ | `notFound` | 4004 | 404 | ITEM NOT FOUND |
667
+ | `invalidJson` | 4005 | 400 | INVALID JSON |
668
+ | `invalidRequest` | 4016 | 400 | INVALID REQUEST DATA |
669
+ | `invalidID` | 4014 | 400 | ID is invalid |
670
+ | `invalidFilter` | 4026 | 400 | INVALID FILTER |
671
+ | `invalidStartDate` | 4020 | 400 | INVALID START DATE |
672
+ | `invalidEndDate` | 4021 | 400 | INVALID END DATE |
673
+ | `invalidDateFormat` | 4019 | 400 | INVALID DATE FORMAT |
674
+ | `invalidAPIKey` | 4017 | 400 | INVALID REQUEST - API MAY KEY INVALID |
675
+ | `invalidUserNameUpdate` | 4027 | 400 | UNABLE TO UPDATE USERNAME |
676
+ | `imageSizeLimit` | 4028 | 400 | IMAGE SIZE LIMIT 100KB EXCEEDED |
677
+ | `emailRequired` | 4004 | 404 | NO EMAIL PROVIDED, CHECK CUSTOMER EMAIL |
678
+ | `mobilePhoneRequired` | 4004 | 404 | NO MOBILE PHONE PROVIDED, CHECK CUSTOMER CONTACTS |
679
+ | `invalidCadenceType` | 5001 | 500 | INVALID CADENCE TYPE |
680
+
681
+ Consuming services typically define their own `errorList` that extends or mirrors this pattern:
682
+
683
+ ```js
684
+ const errorList = {
685
+ paymentFailed: { statusCode: 400, errorCode: '4030', message: 'PAYMENT FAILED' }
686
+ };
687
+
688
+ // Throw it failure() will format it correctly
689
+ throw errorList.paymentFailed;
690
+ ```
691
+
692
+ ---
693
+
694
+ ## Examples
695
+
696
+ ### Complete Lambda Handler (Read)
697
+
698
+ ```js
699
+ import {
700
+ accessRightsUtils, checkModule,
701
+ createFilters, createSort, findAll,
702
+ success, failure
703
+ } from 'piper-utils';
704
+
705
+ export async function getOrders(event) {
706
+ try {
707
+ checkModule('orders', event);
708
+
709
+ const where = createFilters(event, orderFilter);
710
+ const order = createSort(event, orderFilter);
711
+ const query = event.queryStringParameters || {};
712
+
713
+ const result = await findAll(Order, {
714
+ where,
715
+ order,
716
+ limit: parseInt(query.limit || '20'),
717
+ offset: parseInt(query.offset || '0')
718
+ });
719
+
720
+ return success(result);
721
+ } catch (err) {
722
+ return failure(err);
723
+ }
724
+ }
725
+ ```
726
+
727
+ > Note: `createFilters` automatically scopes the query to the user's authorized business IDs via `accessRightsUtils(event)`. You do not need to add `where.businessId` manually.
728
+
729
+ ### Complete Lambda Handler (Write)
730
+
731
+ ```js
732
+ import {
733
+ parseBody, getCurrentUser, checkWriteAccess,
734
+ success, failure
735
+ } from 'piper-utils';
736
+
737
+ export async function updateOrder(event) {
738
+ try {
739
+ const user = getCurrentUser(event);
740
+ // => { username: 'john@example.com', id: 42 }
741
+
742
+ const businessId = checkWriteAccess(event);
743
+ // Throws 401 if user lacks write/admin role for the businessId in body
744
+
745
+ const body = parseBody(event);
746
+
747
+ const order = await Order.findOne({
748
+ where: { id: event.pathParameters.id, businessId }
749
+ });
750
+
751
+ if (!order) throw { statusCode: 404, errorCode: '4004', message: 'Order not found' };
752
+
753
+ order.set({ ...body, updatedBy: user.id });
754
+ await order.save();
755
+
756
+ return success(order);
757
+ } catch (e) {
758
+ return failure(e);
759
+ }
760
+ }
761
+ ```
762
+
763
+ ### S3 File Processing Pipeline
764
+
765
+ ```js
766
+ import { watchBucket, success, failure } from 'piper-utils';
767
+
768
+ export async function importHandler(event) {
769
+ try {
770
+ return await watchBucket({
771
+ event,
772
+ dynamoConfigTable: 'Config-Dev',
773
+ dynamoConfigKey: 'orderImportConfig',
774
+ s3Bucket: 'order-imports-dev',
775
+ snsTopic: 'order-import-topic-dev',
776
+ transformer: async (parsedJson) => {
777
+ // Each file contains a JSON order object
778
+ await Order.create(parsedJson);
779
+ },
780
+ errorHandlerPerFile: (err, filePath) => {
781
+ console.error(`Import failed for ${filePath}:`, err);
782
+ return false; // don't suppress — let retry folders handle it
783
+ }
784
+ });
785
+ } catch (e) {
786
+ return failure(e);
787
+ }
788
+ }
789
+ ```
790
+
791
+ ### Partner Access Enrichment
792
+
793
+ ```js
794
+ import {
795
+ getEffectivePartnerId, enrichEventWithPartnerAccess,
796
+ accessRightsUtils, success, failure
797
+ } from 'piper-utils';
798
+
799
+ export async function getPartnerOrders(event) {
800
+ try {
801
+ const partnerId = getEffectivePartnerId(event);
802
+
803
+ if (partnerId) {
804
+ // Look up which businesses belong to this partner
805
+ const partnerBusinessIds = await getPartnerBusinessIds(partnerId);
806
+ // Inject them into the event so accessRightsUtils includes them
807
+ enrichEventWithPartnerAccess(event, partnerBusinessIds, 'R');
808
+ }
809
+
810
+ const businessIds = accessRightsUtils(event);
811
+ // Now includes both user's own businesses AND partner businesses
812
+
813
+ // ... query with businessIds
814
+ return success(results);
815
+ } catch (e) {
816
+ return failure(e);
817
+ }
818
+ }
819
+ ```
820
+
821
+ ---
822
+
823
+ ## Peer Dependencies
824
+
825
+ These must be installed in the consuming project:
826
+
827
+ | Package | Version |
828
+ |---------|---------|
829
+ | `bluebird` | >= 3.7.0 |
830
+ | `dayjs` | ^1.11.13 |
831
+ | `lodash` | >= 4.17.15 |
832
+ | `sequelize` | >= 6.6.2 |
833
+ | `umzug` | >= 3.2.1 |
834
+
835
+ ## Testing
836
+
837
+ ```bash
838
+ npm test # unit tests (BUILD_ENV=test)
839
+ npm run itest # integration tests (BUILD_ENV=development)
840
+ ```
841
+
842
+ Tests use Jasmine 5 with NYC coverage. Coverage thresholds: branches >= 70%, functions >= 70%, statements >= 85%, lines >= 80%.
843
+
844
+ ## License
845
+
846
+ Private - Copyright (c) Piper