@flusys/nestjs-shared 6.4.0 → 7.0.0-beta.1

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.
Files changed (111) hide show
  1. package/README.md +437 -0
  2. package/cjs/classes/api-service.class.js +71 -8
  3. package/cjs/classes/api-service.class.spec.js +220 -0
  4. package/cjs/classes/disabled-event-bus.class.js +15 -0
  5. package/cjs/classes/disabled-event-bus.class.spec.js +40 -0
  6. package/cjs/classes/domain-event.publisher.js +76 -0
  7. package/cjs/classes/domain-event.publisher.spec.js +234 -0
  8. package/cjs/classes/event-bus.registry.js +69 -0
  9. package/cjs/classes/event-bus.registry.spec.js +116 -0
  10. package/cjs/classes/event-transports/event-transport.registry.js +41 -0
  11. package/cjs/classes/event-transports/event-transport.registry.spec.js +31 -0
  12. package/cjs/classes/event-transports/index.js +23 -0
  13. package/cjs/classes/event-transports/kafka-transport.optional.js +151 -0
  14. package/cjs/classes/event-transports/kafka-transport.optional.spec.js +199 -0
  15. package/cjs/classes/event-transports/memory-transport.js +54 -0
  16. package/cjs/classes/event-transports/memory-transport.spec.js +58 -0
  17. package/cjs/classes/event-transports/rabbitmq-transport.optional.js +184 -0
  18. package/cjs/classes/event-transports/rabbitmq-transport.optional.spec.js +256 -0
  19. package/cjs/classes/hybrid-event-bus.class.js +261 -0
  20. package/cjs/classes/hybrid-event-bus.class.spec.js +189 -0
  21. package/cjs/classes/index.js +5 -0
  22. package/cjs/constants/index.js +24 -0
  23. package/cjs/decorators/index.js +1 -0
  24. package/cjs/decorators/on-domain-event.decorator.js +13 -0
  25. package/cjs/decorators/on-domain-event.decorator.spec.js +50 -0
  26. package/cjs/entities/user-root.js +2 -2
  27. package/cjs/enums/event-action.enum.js +18 -0
  28. package/cjs/enums/index.js +1 -0
  29. package/cjs/interfaces/event-bus.interface.js +4 -0
  30. package/cjs/interfaces/index.js +1 -0
  31. package/cjs/modules/event-bus/event-bus.explorer.js +124 -0
  32. package/cjs/modules/event-bus/event-bus.explorer.spec.js +189 -0
  33. package/cjs/modules/event-bus/event-bus.integration.spec.js +254 -0
  34. package/cjs/modules/event-bus/event-bus.module.js +94 -0
  35. package/cjs/modules/event-bus/event-bus.module.spec.js +149 -0
  36. package/cjs/modules/event-bus/module-events.provider.js +39 -0
  37. package/cjs/modules/event-bus/module-events.provider.spec.js +56 -0
  38. package/cjs/modules/index.js +3 -0
  39. package/cjs/utils/event-pattern.util.js +98 -0
  40. package/cjs/utils/event-pattern.util.spec.js +234 -0
  41. package/cjs/utils/index.js +2 -0
  42. package/cjs/utils/optional-module.util.js +35 -0
  43. package/cjs/utils/optional-module.util.spec.js +14 -0
  44. package/classes/api-service.class.d.ts +11 -1
  45. package/classes/disabled-event-bus.class.d.ts +6 -0
  46. package/classes/domain-event.publisher.d.ts +14 -0
  47. package/classes/event-bus.registry.d.ts +14 -0
  48. package/classes/event-transports/event-transport.registry.d.ts +10 -0
  49. package/classes/event-transports/index.d.ts +2 -0
  50. package/classes/event-transports/kafka-transport.optional.d.ts +21 -0
  51. package/classes/event-transports/memory-transport.d.ts +13 -0
  52. package/classes/event-transports/rabbitmq-transport.optional.d.ts +27 -0
  53. package/classes/hybrid-event-bus.class.d.ts +32 -0
  54. package/classes/index.d.ts +5 -0
  55. package/constants/index.d.ts +6 -0
  56. package/decorators/index.d.ts +1 -0
  57. package/decorators/on-domain-event.decorator.d.ts +1 -0
  58. package/enums/event-action.enum.d.ts +7 -0
  59. package/enums/index.d.ts +1 -0
  60. package/fesm/classes/api-service.class.js +71 -8
  61. package/fesm/classes/api-service.class.spec.js +220 -0
  62. package/fesm/classes/disabled-event-bus.class.js +11 -0
  63. package/fesm/classes/disabled-event-bus.class.spec.js +36 -0
  64. package/fesm/classes/domain-event.publisher.js +63 -0
  65. package/fesm/classes/domain-event.publisher.spec.js +230 -0
  66. package/fesm/classes/event-bus.registry.js +65 -0
  67. package/fesm/classes/event-bus.registry.spec.js +112 -0
  68. package/fesm/classes/event-transports/event-transport.registry.js +35 -0
  69. package/fesm/classes/event-transports/event-transport.registry.spec.js +27 -0
  70. package/fesm/classes/event-transports/index.js +6 -0
  71. package/fesm/classes/event-transports/kafka-transport.optional.js +144 -0
  72. package/fesm/classes/event-transports/kafka-transport.optional.spec.js +195 -0
  73. package/fesm/classes/event-transports/memory-transport.js +46 -0
  74. package/fesm/classes/event-transports/memory-transport.spec.js +54 -0
  75. package/fesm/classes/event-transports/rabbitmq-transport.optional.js +174 -0
  76. package/fesm/classes/event-transports/rabbitmq-transport.optional.spec.js +252 -0
  77. package/fesm/classes/hybrid-event-bus.class.js +217 -0
  78. package/fesm/classes/hybrid-event-bus.class.spec.js +185 -0
  79. package/fesm/classes/index.js +5 -0
  80. package/fesm/constants/index.js +7 -0
  81. package/fesm/decorators/index.js +1 -0
  82. package/fesm/decorators/on-domain-event.decorator.js +10 -0
  83. package/fesm/decorators/on-domain-event.decorator.spec.js +46 -0
  84. package/fesm/entities/user-root.js +1 -1
  85. package/fesm/enums/event-action.enum.js +8 -0
  86. package/fesm/enums/index.js +1 -0
  87. package/fesm/interfaces/event-bus.interface.js +1 -0
  88. package/fesm/interfaces/index.js +1 -0
  89. package/fesm/modules/event-bus/event-bus.explorer.js +114 -0
  90. package/fesm/modules/event-bus/event-bus.explorer.spec.js +185 -0
  91. package/fesm/modules/event-bus/event-bus.integration.spec.js +250 -0
  92. package/fesm/modules/event-bus/event-bus.module.js +84 -0
  93. package/fesm/modules/event-bus/event-bus.module.spec.js +145 -0
  94. package/fesm/modules/event-bus/module-events.provider.js +26 -0
  95. package/fesm/modules/event-bus/module-events.provider.spec.js +52 -0
  96. package/fesm/modules/index.js +3 -0
  97. package/fesm/utils/event-pattern.util.js +86 -0
  98. package/fesm/utils/event-pattern.util.spec.js +230 -0
  99. package/fesm/utils/index.js +2 -0
  100. package/fesm/utils/optional-module.util.js +25 -0
  101. package/fesm/utils/optional-module.util.spec.js +10 -0
  102. package/interfaces/event-bus.interface.d.ts +74 -0
  103. package/interfaces/index.d.ts +1 -0
  104. package/modules/event-bus/event-bus.explorer.d.ts +14 -0
  105. package/modules/event-bus/event-bus.module.d.ts +8 -0
  106. package/modules/event-bus/module-events.provider.d.ts +3 -0
  107. package/modules/index.d.ts +3 -0
  108. package/package.json +2 -2
  109. package/utils/event-pattern.util.d.ts +3 -0
  110. package/utils/index.d.ts +2 -0
  111. package/utils/optional-module.util.d.ts +1 -0
package/README.md CHANGED
@@ -232,6 +232,443 @@ export class MyService {
232
232
 
233
233
  `CacheModule.forRoot(true)` connects to Redis automatically when `REDIS_URL` is set; otherwise uses in-memory only.
234
234
 
235
+ ## 8. Hybrid Event Bus
236
+
237
+ The event counterpart of the hybrid cache: one wrapper, transport chosen by configuration.
238
+
239
+ | `USE_EVENT_LABEL` | Behaviour | Extra install |
240
+ | ----------------- | ------------------------------------------------------ | ---------------- |
241
+ | `memory` | In-process only (default) | none |
242
+ | `rabbitmq` | Topic exchange, cross-service | `npm i amqplib` |
243
+ | `kafka` | Single topic, cross-service | `npm i kafkajs` |
244
+ | `hybrid` | Both layers, duplicate delivery suppressed by event id | broker of choice |
245
+
246
+ ### Registering
247
+
248
+ `EventBusModule` is optional - register it only in an app that actually publishes or
249
+ consumes events.
250
+
251
+ ```typescript
252
+ import { EventBusModule } from '@flusys/nestjs-shared/modules';
253
+
254
+ @Module({
255
+ imports: [EventBusModule.forRoot({ serviceName: 'flusysnest' })],
256
+ })
257
+ export class AppModule {}
258
+ ```
259
+
260
+ Leave it out and nothing breaks: no package injects the bus as a hard dependency, and
261
+ `publishDomainEvent` - including the calls `ApiService` makes for you - finds no bus and
262
+ returns without doing anything. There is no placeholder registration to add just to keep
263
+ DI happy. `forRoot({ enabled: false })` is for the other case: the app supports events but
264
+ this deployment has them switched off, and you want that stated at startup.
265
+
266
+ The one thing an unregistered app gives up is consumption - `@OnDomainEvent` methods are
267
+ bound at bootstrap by an explorer that ships inside the module, so without it handlers
268
+ never run and nothing warns about it.
269
+
270
+ ### Running against a real broker
271
+
272
+ Both brokers need their driver installed - `npm i amqplib` or `npm i kafkajs`. The
273
+ transports load it at runtime, so the package builds and boots without either.
274
+
275
+ - **One delivery per service, not per replica.** RabbitMQ binds a single queue named
276
+ `<serviceName>.events` and Kafka joins one consumer group named `<serviceName>-events`,
277
+ so two replicas of the same service compete for each event and only one of them handles
278
+ it. Give a service that must see every event on every replica its own `queue` / `groupId`.
279
+ - **A publisher consumes its own events.** In `rabbitmq` and `kafka` mode the local
280
+ handlers run off the broker round trip, which is what makes a single code path work
281
+ across services. In `hybrid` mode the in-process copy arrives first and the broker copy
282
+ is dropped by event id, so a handler still runs exactly once.
283
+ - **RabbitMQ reconnects on its own.** A dropped connection *and* a channel killed by a
284
+ protocol error both schedule one reconnect (`reconnectDelay`, 5s by default), which
285
+ re-binds every pattern onto the fresh channel. While it is down, publishes are dropped
286
+ rather than queued - events must never fail the operation that produced them.
287
+ - **Kafka's consumer is built on the first bind**, not at connect. A missing topic or an
288
+ unavailable group coordinator therefore fails consumption alone and leaves the producer
289
+ working, instead of taking the whole transport down to in-process delivery.
290
+
291
+ ### Publishing
292
+
293
+ Every service extending `ApiService` publishes automatically once its module enables
294
+ events — no per-service code. Turn it on in the module's own config:
295
+
296
+ ```typescript
297
+ StorageModule.forRoot({
298
+ config: {
299
+ ...dbConfig,
300
+ events: { enabled: true, includePayload: true },
301
+ },
302
+ });
303
+ ```
304
+
305
+ #### Every `events` option
306
+
307
+ | Option | Type | Default | Effect |
308
+ | ----------------- | --------------------------- | ------- | ---------------------------------------------------------------------- |
309
+ | `enabled` | `boolean` | `false` | Master switch for the module. Everything else is ignored when off. |
310
+ | `actions` | `(EventAction \| string)[]` | all | Allowlist of action names. Omit to publish every action. |
311
+ | `entities` | `string[]` | all | Allowlist of entity names. Omit to publish every entity. |
312
+ | `excludeEntities` | `string[]` | `[]` | Denylist, applied before `entities`. |
313
+ | `includePayload` | `boolean` | `true` | Attach the saved rows as `payload.data`. Ids and metadata always ride. |
314
+
315
+ Three rules that decide what actually reaches a consumer:
316
+
317
+ 1. `actions` is **one allowlist covering CRUD and domain actions together**. Listing only
318
+ `['logged-in']` silences that module's `created`/`updated`/`deleted` too - name
319
+ everything you want kept, or omit `actions` entirely.
320
+ 2. `entities` matches the entity **in the event name**, which for a domain action is not
321
+ always the table you expect: `auth.session.logged-in` is filtered by `session`, not
322
+ `appuser`.
323
+ 3. Entity names are the ones the services register, so some are `snake_case`
324
+ (`form_result`, `file_manager`) and some are `camelCase` (`storageConfig`,
325
+ `emailConfig`, `translationKey`). The per-module tables below list them verbatim.
326
+
327
+ Event names are always `<module>.<entity>.<action>`. CRUD lifecycle actions are
328
+ `created`, `updated`, `deleted`, `restored`, `purged`. Events are published after the
329
+ transaction commits and never fail the write that produced them.
330
+
331
+ The module's own `events` block is the single authority on whether it publishes: passing
332
+ an `eventBus` explicitly chooses _which_ bus to publish on, it never overrides the switch,
333
+ so `enabled: false` silences the module everywhere. `delete` and `restore` carry ids only -
334
+ there is no row left to attach.
335
+
336
+ Payloads are walked before they leave the process: credential-like fields become
337
+ `[REDACTED]`, a relation that loops back becomes `[CIRCULAR]`, anything past 8 levels deep
338
+ becomes `[TRUNCATED]`, and a Buffer becomes `[BINARY]`. Saved TypeORM rows with
339
+ bidirectional relations are therefore safe to publish.
340
+
341
+ ### Registering each module
342
+
343
+ Every feature module takes the same `events` block in its own config, and every one of
344
+ them also has a `forRootAsync` variant that returns the identical `config`. The blocks
345
+ below spell out all options with that module's real entity and action names, so each is
346
+ copy-pasteable. `EventAction` comes from `@flusys/nestjs-shared/enums`; the action and
347
+ entity constants come from each package's own `config` entry point.
348
+
349
+ In an application, keep the per-module blocks in one map rather than scattering them
350
+ across registrations - `flusysnest/src/config/modules.config.ts` is the reference:
351
+
352
+ ```typescript
353
+ const domainEventsEnabled = envConfig.getBoolean('ENABLE_DOMAIN_EVENTS', false);
354
+
355
+ export const moduleEventsConfig: Record<FeatureModuleName, IModuleEventsConfig> = {
356
+ storage: {
357
+ enabled: domainEventsEnabled,
358
+ actions: [...CRUD_EVENT_ACTIONS, STORAGE_EVENT_ACTIONS.UPLOADED, STORAGE_EVENT_ACTIONS.REMOVED],
359
+ entities: [
360
+ STORAGE_EVENT_ENTITIES.FILE,
361
+ STORAGE_EVENT_ENTITIES.FOLDER,
362
+ STORAGE_EVENT_ENTITIES.STORAGE_CONFIG,
363
+ ],
364
+ excludeEntities: [STORAGE_EVENT_ENTITIES.STORAGE_CONFIG],
365
+ includePayload: true,
366
+ },
367
+ // ...one entry per feature module
368
+ };
369
+
370
+ export function getBaseModuleConfig(moduleName: FeatureModuleName) {
371
+ return { ...getDbConfig(), events: moduleEventsConfig[moduleName] };
372
+ }
373
+
374
+ AuthModule.forRoot({ config: { ...authConfig, ...getBaseModuleConfig('auth') } });
375
+ ```
376
+
377
+ Spelling `actions` and `entities` out in full is the same as omitting them, and makes the
378
+ file a readable inventory of what each module can publish - delete a line to stop
379
+ publishing it. The trade-off is that a package gaining a new entity or action stays silent
380
+ until it is added here, so keep a test asserting these lists cover each package's own
381
+ `*_EVENT_ACTIONS` / `*_EVENT_ENTITIES` constants. One env flag still arms every module, but
382
+ each keeps its own block, so narrowing one module never touches the others. Note the two `excludeEntities` entries that ship by
383
+ default: `storageConfig` and `emailConfig` rows hold S3, Azure, SFTP and SMTP credentials
384
+ in a `config` blob, so they stay off the bus entirely.
385
+
386
+ #### auth
387
+
388
+ ```typescript
389
+ import { AUTH_EVENT_ACTIONS, AUTH_EVENT_ENTITIES } from '@flusys/nestjs-auth/config';
390
+
391
+ AuthModule.forRoot({
392
+ config: {
393
+ ...dbConfig,
394
+ events: {
395
+ enabled: true,
396
+ actions: [EventAction.CREATED, AUTH_EVENT_ACTIONS.LOGGED_IN],
397
+ entities: [AUTH_EVENT_ENTITIES.USER, AUTH_EVENT_ENTITIES.SESSION],
398
+ excludeEntities: ['company_branch'],
399
+ includePayload: false,
400
+ },
401
+ },
402
+ });
403
+ ```
404
+
405
+ | Entity | Actions it can publish |
406
+ | ------------------------- | ------------------------------------------------------------------------------------------------------- |
407
+ | `appuser` | CRUD + `registered`, `password-changed`, `password-reset-requested`, `password-reset`, `email-verified` |
408
+ | `company` | CRUD |
409
+ | `company_branch` | CRUD |
410
+ | `session` | `logged-in`, `logged-out`, `token-refreshed`, `company-switched` |
411
+ | `user_company_permission` | `permission-granted`, `permission-revoked` |
412
+
413
+ #### iam
414
+
415
+ ```typescript
416
+ import { IAM_EVENT_ACTIONS, IAM_EVENT_ENTITIES } from '@flusys/nestjs-iam/config';
417
+
418
+ IAMModule.forRoot({
419
+ config: {
420
+ ...dbConfig,
421
+ events: {
422
+ enabled: true,
423
+ actions: [EventAction.CREATED, EventAction.DELETED, IAM_EVENT_ACTIONS.PERMISSIONS_ASSIGNED],
424
+ entities: ['role', IAM_EVENT_ENTITIES.USER_ROLE],
425
+ excludeEntities: ['action'],
426
+ includePayload: true,
427
+ },
428
+ },
429
+ });
430
+ ```
431
+
432
+ | Entity | Actions it can publish |
433
+ | ----------------------------------------------------------- | ---------------------- |
434
+ | `action`, `role` | CRUD |
435
+ | `user_action`, `role_action`, `company_action`, `user_role` | `permissions-assigned` |
436
+
437
+ #### storage
438
+
439
+ ```typescript
440
+ import { STORAGE_EVENT_ACTIONS, STORAGE_EVENT_ENTITIES } from '@flusys/nestjs-storage/config';
441
+
442
+ StorageModule.forRoot({
443
+ config: {
444
+ ...dbConfig,
445
+ events: {
446
+ enabled: true,
447
+ actions: [EventAction.CREATED, EventAction.DELETED, STORAGE_EVENT_ACTIONS.UPLOADED],
448
+ entities: [STORAGE_EVENT_ENTITIES.FILE, 'folder'],
449
+ excludeEntities: ['storageConfig'],
450
+ includePayload: true,
451
+ },
452
+ },
453
+ });
454
+ ```
455
+
456
+ | Entity | Actions it can publish |
457
+ | --------------- | ---------------------------- |
458
+ | `file_manager` | CRUD + `uploaded`, `removed` |
459
+ | `folder` | CRUD |
460
+ | `storageConfig` | CRUD |
461
+
462
+ Uploads and removals report provider keys in `payload.ids`, with `location`,
463
+ `storageConfigId`, `totalSize` and `count` in `payload.metadata`.
464
+
465
+ #### form-builder
466
+
467
+ ```typescript
468
+ import { FORM_BUILDER_EVENT_ACTIONS } from '@flusys/nestjs-form-builder/config';
469
+
470
+ FormBuilderModule.forRoot({
471
+ config: {
472
+ ...dbConfig,
473
+ events: {
474
+ enabled: true,
475
+ actions: [FORM_BUILDER_EVENT_ACTIONS.SUBMITTED],
476
+ entities: ['form_result'],
477
+ excludeEntities: [],
478
+ includePayload: false,
479
+ },
480
+ },
481
+ });
482
+ ```
483
+
484
+ | Entity | Actions it can publish |
485
+ | ------------- | --------------------------------- |
486
+ | `form` | CRUD |
487
+ | `form_result` | CRUD + `submitted`, `draft-saved` |
488
+
489
+ Submissions carry `formId`, `isDraft` and `schemaVersion` in `payload.metadata`, so an
490
+ audit consumer can work with `includePayload: false` and never see answer data.
491
+
492
+ #### email
493
+
494
+ ```typescript
495
+ import { EMAIL_EVENT_ACTIONS, EMAIL_EVENT_ENTITIES } from '@flusys/nestjs-email/config';
496
+
497
+ EmailModule.forRoot({
498
+ config: {
499
+ ...dbConfig,
500
+ events: {
501
+ enabled: true,
502
+ actions: [EMAIL_EVENT_ACTIONS.SENT, EMAIL_EVENT_ACTIONS.FAILED],
503
+ entities: [EMAIL_EVENT_ENTITIES.MESSAGE],
504
+ excludeEntities: ['emailConfig'],
505
+ includePayload: false,
506
+ },
507
+ },
508
+ });
509
+ ```
510
+
511
+ | Entity | Actions it can publish |
512
+ | --------------- | ---------------------- |
513
+ | `emailConfig` | CRUD |
514
+ | `emailTemplate` | CRUD |
515
+ | `email_message` | `sent`, `failed` |
516
+
517
+ #### event-manager
518
+
519
+ ```typescript
520
+ import { EVENT_MANAGER_EVENT_ACTIONS } from '@flusys/nestjs-event-manager/config';
521
+
522
+ EventManagerModule.forRoot({
523
+ config: {
524
+ ...dbConfig,
525
+ events: {
526
+ enabled: true,
527
+ actions: [EventAction.CREATED, EVENT_MANAGER_EVENT_ACTIONS.PARTICIPANT_STATUS_CHANGED],
528
+ entities: ['event'],
529
+ excludeEntities: [],
530
+ includePayload: true,
531
+ },
532
+ },
533
+ });
534
+ ```
535
+
536
+ | Entity | Actions it can publish |
537
+ | ------- | ----------------------------------- |
538
+ | `event` | CRUD + `participant-status-changed` |
539
+
540
+ The participant event is keyed by the **event** id, with `participantId`,
541
+ `participantUserId` and `status` in `payload.metadata`.
542
+
543
+ #### notification
544
+
545
+ ```typescript
546
+ import {
547
+ NOTIFICATION_EVENT_ACTIONS,
548
+ NOTIFICATION_EVENT_ENTITIES,
549
+ } from '@flusys/nestjs-notification/config';
550
+
551
+ NotificationModule.forRoot({
552
+ config: {
553
+ ...dbConfig,
554
+ enableRealtime: true,
555
+ events: {
556
+ enabled: true,
557
+ actions: [NOTIFICATION_EVENT_ACTIONS.SENT, NOTIFICATION_EVENT_ACTIONS.READ],
558
+ entities: [NOTIFICATION_EVENT_ENTITIES.NOTIFICATION],
559
+ excludeEntities: [],
560
+ includePayload: false,
561
+ },
562
+ },
563
+ });
564
+ ```
565
+
566
+ | Entity | Actions it can publish |
567
+ | -------------- | --------------------------------- |
568
+ | `notification` | CRUD + `sent`, `read`, `all-read` |
569
+
570
+ Subscribing to `notification.**` while the notification module itself reacts to other
571
+ modules' events is the usual way to fan a domain event out to users.
572
+
573
+ #### localization
574
+
575
+ ```typescript
576
+ import { LOCALIZATION_EVENT_ACTIONS } from '@flusys/nestjs-localization/config';
577
+
578
+ LocalizationModule.forRoot({
579
+ config: {
580
+ ...dbConfig,
581
+ defaultLanguageCode: 'en',
582
+ events: {
583
+ enabled: true,
584
+ actions: [LOCALIZATION_EVENT_ACTIONS.BULK_UPDATED],
585
+ entities: ['translation'],
586
+ excludeEntities: ['translationKey'],
587
+ includePayload: false,
588
+ },
589
+ },
590
+ });
591
+ ```
592
+
593
+ | Entity | Actions it can publish |
594
+ | ---------------- | ---------------------- |
595
+ | `language` | CRUD |
596
+ | `translationKey` | CRUD |
597
+ | `translation` | CRUD + `bulk-updated` |
598
+
599
+ `bulk-updated` carries every written id plus `count` in `payload.metadata`, and is not
600
+ published when there was nothing to write - useful for cache busting a translation CDN.
601
+
602
+ #### task-manager
603
+
604
+ ```typescript
605
+ import { TASK_MANAGER_EVENT_ACTIONS } from '@flusys/nestjs-task-manager/config';
606
+
607
+ TaskManagerModule.forRoot({
608
+ config: {
609
+ ...dbConfig,
610
+ events: {
611
+ enabled: true,
612
+ actions: [TASK_MANAGER_EVENT_ACTIONS.MOVED, TASK_MANAGER_EVENT_ACTIONS.ASSIGNED],
613
+ entities: ['task', 'task_list'],
614
+ excludeEntities: ['task_comment'],
615
+ includePayload: true,
616
+ },
617
+ },
618
+ });
619
+ ```
620
+
621
+ | Entity | Actions it can publish |
622
+ | ------------------------------------------ | -------------------------- |
623
+ | `task` | CRUD + `moved`, `assigned` |
624
+ | `task_list` | CRUD + `reordered` |
625
+ | `task_board`, `task_label`, `task_comment` | CRUD |
626
+
627
+ `moved` carries `fromListId`/`toListId`; `assigned` carries the assignee ids. Domain
628
+ events carry a `payload.metadata` descriptor that is attached even when `includePayload`
629
+ is off.
630
+
631
+ ### Publishing your own
632
+
633
+ ```typescript
634
+ import { publishDomainEvent } from '@flusys/nestjs-shared/classes';
635
+
636
+ await publishDomainEvent({
637
+ module: 'billing',
638
+ entity: 'invoice',
639
+ action: 'settled',
640
+ ids: [invoice.id],
641
+ metadata: { amount: invoice.total },
642
+ user,
643
+ });
644
+ ```
645
+
646
+ Inside a service that already extends `ApiService`, call
647
+ `this.publishDomainAction(action, { ... })` - it fills in the module and entity for you.
648
+
649
+ ### Consuming
650
+
651
+ ```typescript
652
+ import { OnDomainEvent } from '@flusys/nestjs-shared/decorators';
653
+ import { DomainEvent } from '@flusys/nestjs-shared/interfaces';
654
+
655
+ @Injectable()
656
+ export class AuditConsumer {
657
+ @OnDomainEvent('storage.*.created', 'auth.appuser.deleted')
658
+ async handle(event: DomainEvent) {
659
+ // event.payload.ids / .data / .metadata, event.actor, event.action
660
+ }
661
+ }
662
+ ```
663
+
664
+ `DomainEvent` is the standard envelope shape - it covers both CRUD lifecycle and domain
665
+ actions, so one type fits every handler. `*` matches exactly one segment; `**` (or `#`)
666
+ matches the rest. Handlers are discovered at bootstrap on any singleton provider or
667
+ controller registered with Nest - a REQUEST-scoped provider has no instance at that point,
668
+ so put consumers on singletons. Injecting `EVENT_BUS_INSTANCE` gives direct
669
+ `publish` / `subscribe` access - mark it `@Optional()`, so the service still resolves in an
670
+ app that never registers `EventBusModule`.
671
+
235
672
  ## License
236
673
 
237
674
  MIT © FLUSYS
@@ -13,8 +13,10 @@ const _typeorm = require("typeorm");
13
13
  const _constants = require("../constants");
14
14
  const _logactiondecorator = require("../decorators/log-action.decorator");
15
15
  const _dtos = require("../dtos");
16
+ const _eventactionenum = require("../enums/event-action.enum");
16
17
  const _interfaces = require("../interfaces");
17
18
  const _utilsservice = require("../modules/utils/utils.service");
19
+ const _domaineventpublisher = require("./domain-event.publisher");
18
20
  const _hybridcacheclass = require("./hybrid-cache.class");
19
21
  function _define_property(obj, key, value) {
20
22
  if (key in obj) {
@@ -38,6 +40,18 @@ function _ts_decorate(decorators, target, key, desc) {
38
40
  function _ts_metadata(k, v) {
39
41
  if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v);
40
42
  }
43
+ const DEFAULT_EVENT_MODULE = 'app';
44
+ const OPERATION_EVENT_ACTIONS = {
45
+ insert: _eventactionenum.EventAction.CREATED,
46
+ insertMany: _eventactionenum.EventAction.CREATED,
47
+ update: _eventactionenum.EventAction.UPDATED,
48
+ updateMany: _eventactionenum.EventAction.UPDATED,
49
+ bulkUpsert: _eventactionenum.EventAction.UPDATED
50
+ };
51
+ const DELETE_EVENT_ACTIONS = {
52
+ delete: _eventactionenum.EventAction.DELETED,
53
+ restore: _eventactionenum.EventAction.RESTORED
54
+ };
41
55
  let ApiService = class ApiService {
42
56
  resolveEntity() {
43
57
  return this._entity;
@@ -74,7 +88,7 @@ let ApiService = class ApiService {
74
88
  saved,
75
89
  returnFirst: true
76
90
  };
77
- });
91
+ }, user);
78
92
  }
79
93
  async insertMany(dtos, user) {
80
94
  return this.executeInTransaction('insertMany', async (qr)=>{
@@ -86,7 +100,7 @@ let ApiService = class ApiService {
86
100
  saved,
87
101
  returnFirst: false
88
102
  };
89
- });
103
+ }, user);
90
104
  }
91
105
  async update(dto, user) {
92
106
  return this.executeInTransaction('update', async (qr)=>{
@@ -98,7 +112,7 @@ let ApiService = class ApiService {
98
112
  saved,
99
113
  returnFirst: true
100
114
  };
101
- });
115
+ }, user);
102
116
  }
103
117
  async updateMany(dtos, user) {
104
118
  return this.executeInTransaction('updateMany', async (qr)=>{
@@ -110,17 +124,22 @@ let ApiService = class ApiService {
110
124
  saved,
111
125
  returnFirst: false
112
126
  };
113
- });
127
+ }, user);
114
128
  }
115
129
  async bulkUpsert(toInsert, toUpdate, user) {
116
130
  return this.executeInTransaction('bulkUpsert', async (qr)=>{
117
131
  const allResults = [];
132
+ const eventGroups = [];
118
133
  if (toInsert.length > 0) {
119
134
  await this.beforeInsertOperation(toInsert, user, qr);
120
135
  const insertedEntities = await this.convertRequestDtoToEntity(toInsert, user);
121
136
  const insertedSaved = await qr.manager.save(this.repository.target, insertedEntities);
122
137
  await this.afterInsertOperation(this.ensureArray(insertedSaved), toInsert, user, qr);
123
138
  allResults.push(...this.ensureArray(insertedSaved));
139
+ eventGroups.push({
140
+ action: _eventactionenum.EventAction.CREATED,
141
+ entities: this.ensureArray(insertedSaved)
142
+ });
124
143
  }
125
144
  if (toUpdate.length > 0) {
126
145
  await this.beforeUpdateOperation(toUpdate, user, qr);
@@ -128,12 +147,17 @@ let ApiService = class ApiService {
128
147
  const updatedSaved = await qr.manager.save(this.repository.target, updatedEntities);
129
148
  await this.afterUpdateOperation(this.ensureArray(updatedSaved), toUpdate, user, qr);
130
149
  allResults.push(...this.ensureArray(updatedSaved));
150
+ eventGroups.push({
151
+ action: _eventactionenum.EventAction.UPDATED,
152
+ entities: this.ensureArray(updatedSaved)
153
+ });
131
154
  }
132
155
  return {
133
156
  saved: allResults,
134
- returnFirst: false
157
+ returnFirst: false,
158
+ eventGroups
135
159
  };
136
- });
160
+ }, user);
137
161
  }
138
162
  async findByIds(ids, user, select) {
139
163
  await this.ensureDataSourceRepository();
@@ -335,6 +359,13 @@ let ApiService = class ApiService {
335
359
  this.clearCacheForAll(),
336
360
  this.clearCacheForId(ids)
337
361
  ]);
362
+ await this.publishLifecycleEvents([
363
+ {
364
+ action: DELETE_EVENT_ACTIONS[option.type] ?? _eventactionenum.EventAction.PURGED,
365
+ entities: ids,
366
+ idsOnly: true
367
+ }
368
+ ], user);
338
369
  return null;
339
370
  } catch (error) {
340
371
  await queryRunner.rollbackTransaction();
@@ -343,6 +374,32 @@ let ApiService = class ApiService {
343
374
  await queryRunner.release();
344
375
  }
345
376
  }
377
+ async publishLifecycleEvents(groups, user) {
378
+ for (const group of groups){
379
+ if (!group.entities.length) continue;
380
+ await (0, _domaineventpublisher.publishDomainEvent)({
381
+ module: this.moduleName ?? DEFAULT_EVENT_MODULE,
382
+ entity: this.entityName,
383
+ action: group.action,
384
+ ids: group.entities.map((entity)=>entity.id),
385
+ ...group.idsOnly ? {} : {
386
+ data: group.entities
387
+ },
388
+ user
389
+ });
390
+ }
391
+ }
392
+ /**
393
+ * Publishes a domain action for this service's entity - anything past plain
394
+ * CRUD, such as a submission or a state transition.
395
+ */ async publishDomainAction(action, input = {}) {
396
+ await (0, _domaineventpublisher.publishDomainEvent)({
397
+ ...input,
398
+ module: this.moduleName ?? DEFAULT_EVENT_MODULE,
399
+ entity: this.entityName,
400
+ action
401
+ });
402
+ }
346
403
  async clearCacheForAll() {
347
404
  await this.utilsService.clearCache(this.entityName, this.cacheManager);
348
405
  }
@@ -363,13 +420,13 @@ let ApiService = class ApiService {
363
420
  value
364
421
  ];
365
422
  }
366
- async executeInTransaction(operation, fn) {
423
+ async executeInTransaction(operation, fn, user) {
367
424
  await this.ensureDataSourceRepository();
368
425
  const qr = this.repository.manager.connection.createQueryRunner();
369
426
  await qr.connect();
370
427
  await qr.startTransaction();
371
428
  try {
372
- const { saved, returnFirst } = await fn(qr);
429
+ const { saved, returnFirst, eventGroups } = await fn(qr);
373
430
  await qr.commitTransaction();
374
431
  const savedArray = this.ensureArray(saved);
375
432
  if (this.isCacheable) {
@@ -378,6 +435,12 @@ let ApiService = class ApiService {
378
435
  this.clearCacheForId(savedArray)
379
436
  ]);
380
437
  }
438
+ await this.publishLifecycleEvents(eventGroups ?? [
439
+ {
440
+ action: OPERATION_EVENT_ACTIONS[operation],
441
+ entities: savedArray
442
+ }
443
+ ], user ?? null);
381
444
  if (returnFirst) {
382
445
  return this.convertEntityToResponseDto(savedArray[0], false);
383
446
  }