@rytass/wms-module-graphql 0.4.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. package/README.md +27 -0
  2. package/adapters/event-emitter.d.ts +29 -0
  3. package/adapters/event-emitter.js +41 -0
  4. package/docs/README.md +3 -0
  5. package/docs/api-reference.md +40 -0
  6. package/docs/events.md +304 -0
  7. package/docs/integration-guide.md +83 -2
  8. package/index.cjs.js +128 -1
  9. package/index.d.ts +2 -0
  10. package/index.js +3 -0
  11. package/lib/events/index.d.ts +3 -0
  12. package/lib/events/wms-mutation-event.constants.d.ts +12 -0
  13. package/lib/events/wms-mutation-event.constants.js +4 -0
  14. package/lib/events/wms-mutation-event.decorator.d.ts +15 -0
  15. package/lib/events/wms-mutation-event.decorator.js +20 -0
  16. package/lib/events/wms-mutation.interceptor.d.ts +19 -0
  17. package/lib/events/wms-mutation.interceptor.js +72 -0
  18. package/lib/graphql.module.d.ts +15 -5
  19. package/lib/graphql.module.js +8 -2
  20. package/lib/mutations/allocate-inventory-order.mutation.js +5 -0
  21. package/lib/mutations/hold-inventory-order.mutation.js +5 -0
  22. package/lib/mutations/loader.mutation.js +7 -0
  23. package/lib/mutations/location.mutation.js +4 -0
  24. package/lib/mutations/material.mutation.js +4 -0
  25. package/lib/mutations/merge-batch.mutation.js +3 -0
  26. package/lib/mutations/quality-inspection-order.mutation.js +3 -0
  27. package/lib/mutations/receive-inventory-order.mutation.js +5 -0
  28. package/lib/mutations/reclassify-inventory-order.mutation.js +3 -0
  29. package/lib/mutations/scrape-order.mutation.js +3 -0
  30. package/lib/mutations/ship-inventory-order.mutation.js +6 -0
  31. package/lib/mutations/split-batch.mutation.js +3 -0
  32. package/lib/mutations/transfer-customer-order.mutation.js +5 -0
  33. package/lib/mutations/transfer-order.mutation.js +6 -0
  34. package/lib/mutations/transfer-vendor-order.mutation.js +5 -0
  35. package/lib/mutations/warehouse-map.mutation.js +3 -0
  36. package/package.json +13 -3
package/README.md CHANGED
@@ -88,6 +88,31 @@ The member-base adapter reuses everything `CasbinGuard` already put on the reque
88
88
 
89
89
  ➡️ **Full design & recipes: [`docs/authorization.md`](./docs/authorization.md)** (the problem, execution order, member-base / custom-JWT / no-guard recipes, the single coupling point, public API, FAQ).
90
90
 
91
+ ## Events & Hooks
92
+
93
+ Intervene in and get notified about mutations. Every root mutation carries a typed **operation event** (`material.created`, `ship-inventory-order.shipped`, …) and fans out **stock domain events** (`stock.allocated`, `stock.shipped`, …) driven by the `ChangeType` state machine. Wire two opt-in hook points via `forRootAsync`:
94
+
95
+ - **`beforeHooks`** run before any DB work — **throw to veto** the mutation (zero side effects).
96
+ - **`afterListeners`** run **after the outermost transaction commits** — never notified about rolled-back changes.
97
+
98
+ ```ts
99
+ WmsModuleGraphqlModule.forRootAsync({
100
+ ...coreOptions,
101
+ beforeHooks: {
102
+ 'ship-inventory-order.shipped': async ({ args }) => {
103
+ if (!allowed(args)) throw new ForbiddenException('blocked');
104
+ },
105
+ },
106
+ afterListeners: {
107
+ 'stock.allocated': async ({ payload }) => { await erp.reserve(payload); },
108
+ },
109
+ });
110
+ ```
111
+
112
+ Post-commit delivery is guaranteed by an `AsyncLocalStorage` dispatch scope (events buffer during the request and flush only after commit). Prefer `@OnEvent`? Opt into the optional [`@nestjs/event-emitter`](https://docs.nestjs.com/techniques/events) bridge from `@rytass/wms-module-graphql/adapters/event-emitter` — every event also emits as `wms.<event>`. Nothing configured ⇒ no events, zero overhead.
113
+
114
+ ➡️ **Full design, the complete 56-event list & limitations: [`docs/events.md`](./docs/events.md)**.
115
+
91
116
  ## DataLoader context
92
117
 
93
118
  The module ships a `HelperModule` (auto-registered) that provides a singleton `LoaderService`. To avoid N+1 queries, the WMS resolvers expect a per-request DataLoader trio at `context.loaders`:
@@ -124,6 +149,7 @@ Full documentation is shipped inside this package under the `docs/` directory (a
124
149
  |---|---|
125
150
  | [`docs/integration-guide.md`](./docs/integration-guide.md) | Step-by-step wiring guide for NestJS + Next.js consumers |
126
151
  | [`docs/authorization.md`](./docs/authorization.md) | Complete resolver-authorization design & integration recipes (member-base / custom JWT / no-guard) |
152
+ | [`docs/events.md`](./docs/events.md) | Mutation event/hook system — before-hooks (veto), post-commit after-listeners, complete event list, event-emitter bridge |
127
153
  | [`docs/api-reference.md`](./docs/api-reference.md) | Complete export catalog with signatures and usage examples |
128
154
 
129
155
  GitHub hosted links (always up-to-date):
@@ -131,6 +157,7 @@ GitHub hosted links (always up-to-date):
131
157
  - [Integration Guide — §4 Backend setup](https://github.com/Rytass/WMS-Module/blob/main/docs/integration-guide.md#4-backend-setup-nestjs--graphql--typeorm)
132
158
  - [Integration Guide — §13.1 Existing GraphQLModule co-existence](https://github.com/Rytass/WMS-Module/blob/main/docs/integration-guide.md#13-integrating-into-an-existing-nestjs--nextjs-project)
133
159
  - [Authorization — design & recipes](https://github.com/Rytass/WMS-Module/blob/main/libs/graphql/docs/authorization.md)
160
+ - [Events & Hooks — design & event list](https://github.com/Rytass/WMS-Module/blob/main/libs/graphql/docs/events.md)
134
161
  - [API Reference — @rytass/wms-module-graphql](https://github.com/Rytass/WMS-Module/blob/main/docs/api-reference.md#rytasswms-module-graphql)
135
162
 
136
163
  ## License
@@ -0,0 +1,29 @@
1
+ import type { Provider } from '@nestjs/common';
2
+ /**
3
+ * Bridges WMS domain events onto `@nestjs/event-emitter`'s `EventEmitter2`, so
4
+ * consumers can subscribe with `@OnEvent('wms.<event-name>')` instead of (or in
5
+ * addition to) the typed `afterListeners`.
6
+ *
7
+ * Pass this into `WmsModuleGraphqlModule.forRootAsync({ eventEmitterBridge })`.
8
+ * Importing this module is the only place `@nestjs/event-emitter` is referenced,
9
+ * keeping it an entirely optional peer dependency — the core graphql module
10
+ * never imports it.
11
+ *
12
+ * Prerequisite: register `EventEmitterModule.forRoot()` in your app so an
13
+ * `EventEmitter2` instance is available for injection.
14
+ *
15
+ * @example
16
+ * ```ts
17
+ * import { wmsEventEmitterBridgeProvider } from '@rytass/wms-module-graphql/adapters/event-emitter';
18
+ *
19
+ * WmsModuleGraphqlModule.forRootAsync({
20
+ * ...coreOptions,
21
+ * eventEmitterBridge: wmsEventEmitterBridgeProvider,
22
+ * });
23
+ *
24
+ * // elsewhere
25
+ * @OnEvent('wms.stock.allocated')
26
+ * handle(payload: WmsStockEventPayload) { ... }
27
+ * ```
28
+ */
29
+ export declare const wmsEventEmitterBridgeProvider: Provider;
@@ -0,0 +1,41 @@
1
+ import { EventEmitter2 } from '@nestjs/event-emitter';
2
+ import { WMS_EVENT_EMITTER_BRIDGE } from '@rytass/wms-module-core';
3
+
4
+ /**
5
+ * Bridges WMS domain events onto `@nestjs/event-emitter`'s `EventEmitter2`, so
6
+ * consumers can subscribe with `@OnEvent('wms.<event-name>')` instead of (or in
7
+ * addition to) the typed `afterListeners`.
8
+ *
9
+ * Pass this into `WmsModuleGraphqlModule.forRootAsync({ eventEmitterBridge })`.
10
+ * Importing this module is the only place `@nestjs/event-emitter` is referenced,
11
+ * keeping it an entirely optional peer dependency — the core graphql module
12
+ * never imports it.
13
+ *
14
+ * Prerequisite: register `EventEmitterModule.forRoot()` in your app so an
15
+ * `EventEmitter2` instance is available for injection.
16
+ *
17
+ * @example
18
+ * ```ts
19
+ * import { wmsEventEmitterBridgeProvider } from '@rytass/wms-module-graphql/adapters/event-emitter';
20
+ *
21
+ * WmsModuleGraphqlModule.forRootAsync({
22
+ * ...coreOptions,
23
+ * eventEmitterBridge: wmsEventEmitterBridgeProvider,
24
+ * });
25
+ *
26
+ * // elsewhere
27
+ * @OnEvent('wms.stock.allocated')
28
+ * handle(payload: WmsStockEventPayload) { ... }
29
+ * ```
30
+ */
31
+ const wmsEventEmitterBridgeProvider = {
32
+ provide: WMS_EVENT_EMITTER_BRIDGE,
33
+ useFactory: (emitter) => ({
34
+ forward: (event) => {
35
+ emitter.emit(`wms.${event.name}`, event.payload);
36
+ },
37
+ }),
38
+ inject: [EventEmitter2],
39
+ };
40
+
41
+ export { wmsEventEmitterBridgeProvider };
package/docs/README.md CHANGED
@@ -8,6 +8,7 @@ Reference material for consumers of `@rytass/wms-module-{core,graphql,react}`.
8
8
  |------------------------------------------------|---------------------------------------------------------------------------------------------------|
9
9
  | [Integration Guide](./integration-guide.md) | Step-by-step instructions for wiring the three packages into a new consumer project (NestJS + Next.js + PostgreSQL). Includes ready-to-paste `_app.tsx`, `app.module.ts`, `next.config.ts`, the 19 Pages Router shell pattern, AuthGuard stub, seed script template, deployment notes, and known issues. |
10
10
  | [Authorization](./authorization.md) | Complete design of the resolver-level authorization protocol (`@WmsOperation` + `WmsAuthGuard` + `authChecker`). Explains the global deny-by-default guard pitfall, execution order, and integration recipes for member-base / Casbin, custom JWT / RBAC, and no-guard setups. Read this if any WMS query/mutation returns *forbidden*. |
11
+ | [Events & Hooks](./events.md) | The mutation event/hook system — `beforeHooks` (veto before any DB work) and `afterListeners` (post-commit notify), the `AsyncLocalStorage` post-commit mechanism, the optional `@nestjs/event-emitter` bridge, and the complete list of 38 operation + 18 stock domain events with payloads. |
11
12
  | [API Reference](./api-reference.md) | Complete export catalog for all three packages — every NestJS module, TypeORM entity, GraphQL resolver, DTO, React component, hook, and utility, organized by role with source paths and usage examples. Mirrors the actual `src/index.ts` barrel exports of `@rytass/wms-module-graphql@0.3.0` and `@rytass/wms-module-{core,react}@0.1.6`. |
12
13
 
13
14
  ## Quick links
@@ -26,6 +27,8 @@ Reference material for consumers of `@rytass/wms-module-{core,graphql,react}`.
26
27
  | Replace the `AuthGuard` stub with real auth | [Integration Guide section 8.4](./integration-guide.md#84-real-auth-integration) |
27
28
  | Authorize WMS resolvers with member-base / Casbin / RBAC | [Authorization](./authorization.md) |
28
29
  | Fix "every WMS query/mutation is forbidden" | [Authorization section 1](./authorization.md#1-the-problem) |
30
+ | Hook into / get notified about mutations | [Events & Hooks](./events.md) |
31
+ | See every event the system emits | [Events & Hooks section 5](./events.md#5-complete-event-list) |
29
32
  | Decide between PostgreSQL and SQLite | [Integration Guide section 9](./integration-guide.md#9-database-support-matrix) |
30
33
  | Build a custom UI on top of the lib's GraphQL operations| [Integration Guide section 7.3](./integration-guide.md#73-graphql-operations--typed-hooks) |
31
34
 
@@ -449,6 +449,46 @@ The adapter imports nothing from member-base — it reads the `payload` / `enfor
449
449
 
450
450
  ---
451
451
 
452
+ ## Events & Hooks
453
+
454
+ Opt-in `beforeHooks` (veto) and post-commit `afterListeners` for every mutation. Each root mutation carries `@WmsMutationEvent({ event })` (already applied); a per-mutation interceptor runs the before-hook, opens an `AsyncLocalStorage` dispatch scope, and flushes collected events after the transaction commits. Stock domain events are collected centrally by a TypeORM subscriber on `*_order_change` inserts. Full design & the complete event list: [`docs/events.md`](./events.md).
455
+
456
+ ### Root exports (`@rytass/wms-module-graphql`, re-exported from `@rytass/wms-module-core`)
457
+
458
+ | Export | Kind | Signature / shape |
459
+ |---|---|---|
460
+ | `WmsMutationEvent` | decorator | `({ event: WmsOperationEventName, resolveId? }) => MethodDecorator` |
461
+ | `WmsEventModuleOptions` | type | `{ beforeHooks?: WmsBeforeHookMap; afterListeners?: WmsAfterListenerMap }` — part of `forRootAsync` options |
462
+ | `WmsBeforeHookMap` | type | `{ [N in WmsEventName]?: (ctx: WmsBeforeHookContext<N>) => void \| Promise<void> }` |
463
+ | `WmsAfterListenerMap` | type | `{ [N in WmsEventName]?: (ctx: WmsAfterEventContext<N>) => void \| Promise<void> }` |
464
+ | `WmsBeforeHookContext` | type | `{ event; args: Record<string, unknown>; executionContext: ExecutionContext }` |
465
+ | `WmsAfterEventContext` | type | `{ event; payload: WmsEventPayloadMap[event] }` |
466
+ | `WmsDomainEvent` | type | `{ name: WmsEventName; payload }` |
467
+ | `WmsEventName` / `WmsOperationEventName` / `WmsStockEventName` | type | event-name unions (38 operation + 18 stock) |
468
+ | `WmsEventPayloadMap` | type | event name → payload |
469
+ | `WmsOperationEventPayload` | type | `{ id: string \| null; result: unknown }` |
470
+ | `WmsStockEventPayload` | type | `{ orderId; itemId; quantityDelta; changeType: ChangeType \| null; inspectionResult? }` |
471
+
472
+ ### Adapter subpath (`@rytass/wms-module-graphql/adapters/event-emitter`)
473
+
474
+ | Export | Kind | Purpose |
475
+ |---|---|---|
476
+ | `wmsEventEmitterBridgeProvider` | `Provider` | Pass to `forRootAsync({ eventEmitterBridge })`; fans every event onto `EventEmitter2` as `wms.<event>` for `@OnEvent`. Requires `@nestjs/event-emitter` (optional peer) + `EventEmitterModule.forRoot()`. |
477
+
478
+ ```ts
479
+ WmsModuleGraphqlModule.forRootAsync({
480
+ ...coreOptions,
481
+ beforeHooks: { 'ship-inventory-order.shipped': async ({ args }) => { if (!ok(args)) throw new ForbiddenException(); } },
482
+ afterListeners: { 'stock.allocated': async ({ payload }) => { await sync(payload); } },
483
+ // optional @OnEvent bridge:
484
+ // eventEmitterBridge: wmsEventEmitterBridgeProvider,
485
+ });
486
+ ```
487
+
488
+ `beforeHooks` / `afterListeners` are **not** part of the GraphQL schema — they are runtime hooks. With none configured the interceptor passes through with zero overhead.
489
+
490
+ ---
491
+
452
492
  ## Resolvers(`@Resolver()` decorator class)
453
493
 
454
494
  以下 Resolver 類別處理 GraphQL `@ResolveField`,解析關聯欄位(防止 N+1):
package/docs/events.md ADDED
@@ -0,0 +1,304 @@
1
+ # Events & Hooks
2
+
3
+ How a consumer **intervenes in** (vetoes) and **gets notified about** WMS
4
+ mutations, with a host-neutral, opt-in API that mirrors the
5
+ [authorization](./authorization.md) layer's wiring.
6
+
7
+ - [1. What you get](#1-what-you-get)
8
+ - [2. Architecture — post-commit guarantee](#2-architecture--post-commit-guarantee)
9
+ - [3. Integration](#3-integration)
10
+ - [4. The `@nestjs/event-emitter` bridge](#4-the-nestjsevent-emitter-bridge)
11
+ - [5. Complete event list](#5-complete-event-list)
12
+ - [6. Payloads](#6-payloads)
13
+ - [7. Guarantees & limitations](#7-guarantees--limitations)
14
+ - [8. Public API](#8-public-api)
15
+
16
+ ---
17
+
18
+ ## 1. What you get
19
+
20
+ Two tiers of events, both opt-in:
21
+
22
+ - **Operation events** — exactly one per root mutation (`material.created`,
23
+ `ship-inventory-order.shipped`, …). 38 in total.
24
+ - **Stock domain events** — driven by the `ChangeType` state machine
25
+ (`stock.allocated`, `stock.shipped`, …), emitted once per stock movement, so a
26
+ single operation can fan out several. 18 in total.
27
+
28
+ Two hook points per event:
29
+
30
+ - **`beforeHooks`** run *before* any DB work. Throwing **vetoes** the whole
31
+ mutation — nothing is written, no after-listener fires. Use for custom
32
+ authorization, validation, quota enforcement.
33
+ - **`afterListeners`** run *after the outermost transaction commits*. Use for
34
+ audit logging, ERP/webhook sync, cache invalidation, notifications.
35
+
36
+ Everything is optional. With nothing configured the module behaves exactly as
37
+ before — no events, zero overhead.
38
+
39
+ ---
40
+
41
+ ## 2. Architecture — post-commit guarantee
42
+
43
+ The hard requirement is that an after-listener must **never** be told about a
44
+ change that was rolled back. WMS guarantees this with an `AsyncLocalStorage`
45
+ dispatch scope opened by a per-mutation interceptor:
46
+
47
+ ```
48
+ GraphQL Mutation (@WmsMutationEvent)
49
+ |
50
+ v
51
+ WmsMutationInterceptor
52
+ 1. beforeHook(event, args) --throw--> VETO (no transaction, clean error)
53
+ 2. als.run(scope, async () => {
54
+ result = await handler() // services open tx, write *_order_change rows
55
+ }) // -> StockChangeSubscriber.afterInsert
56
+ | // collects stock.* events into the scope
57
+ | handler resolved == transaction committed
58
+ v
59
+ 3. flush(scope.buffer)
60
+ afterListener(event) (isolated; one failure never blocks others)
61
+ emitterBridge.forward(event) --> EventEmitter2 (optional)
62
+ ```
63
+
64
+ Why it is post-commit: TypeORM's `dataSource.transaction(cb)` resolves only
65
+ *after* `COMMIT`. The interceptor awaits the whole handler before flushing, so
66
+ the buffer is dispatched strictly after commit. If the transaction rolls back
67
+ the handler rejects, the interceptor never flushes, and the buffered events are
68
+ discarded.
69
+
70
+ Stock events are collected centrally by a single TypeORM subscriber
71
+ (`StockChangeSubscriber`) that listens to every `*_order_change` insert and
72
+ derives the `ChangeType` from the table name and the sign of `quantityChanged`.
73
+ The 12 domain services are untouched, and any future code path that writes a
74
+ change row is covered automatically.
75
+
76
+ ---
77
+
78
+ ## 3. Integration
79
+
80
+ `beforeHooks` / `afterListeners` are typed maps keyed by event name; the payload
81
+ is inferred from the key.
82
+
83
+ ```ts
84
+ import { WmsModuleGraphqlModule } from '@rytass/wms-module-graphql';
85
+ import { ForbiddenException } from '@nestjs/common';
86
+
87
+ WmsModuleGraphqlModule.forRootAsync({
88
+ useFactory: async () => ({}),
89
+ defaultLoaderSerialId: 'DEFAULT',
90
+ defaultLoaderTypeKey: 'DEFAULT',
91
+ defaultLocationKey: 'DEFAULT',
92
+
93
+ // intervene — runs before any DB work; throw to veto
94
+ beforeHooks: {
95
+ 'ship-inventory-order.shipped': async ({ args, executionContext }) => {
96
+ if (!(await canShip(args, executionContext))) {
97
+ throw new ForbiddenException('shipment blocked');
98
+ }
99
+ },
100
+ },
101
+
102
+ // notify — runs after commit
103
+ afterListeners: {
104
+ 'ship-inventory-order.shipped': async ({ payload }) => {
105
+ await erp.markShipped(payload.id, payload.result);
106
+ },
107
+ 'stock.allocated': async ({ payload }) => {
108
+ await erp.reserve(payload.orderId, payload.itemId, payload.quantityDelta);
109
+ },
110
+ },
111
+ });
112
+ ```
113
+
114
+ - A `beforeHook` receives `{ event, args, executionContext }` — the mutation
115
+ **intent** (raw GraphQL args), since no result exists yet. Throw (e.g. a
116
+ `ForbiddenException`) to abort with zero side effects.
117
+ - An `afterListener` receives `{ event, payload }` — the committed result (see
118
+ [§6](#6-payloads)). It may be async; a thrown error is logged and isolated, it
119
+ never affects the committed mutation or other listeners.
120
+
121
+ ---
122
+
123
+ ## 4. The `@nestjs/event-emitter` bridge
124
+
125
+ Prefer decoupled `@OnEvent` subscribers? Opt into the bridge. It is the only
126
+ thing that imports `@nestjs/event-emitter` (an **optional** peer dependency), so
127
+ the core module never depends on it.
128
+
129
+ ```ts
130
+ import { EventEmitterModule } from '@nestjs/event-emitter';
131
+ import { wmsEventEmitterBridgeProvider } from '@rytass/wms-module-graphql/adapters/event-emitter';
132
+
133
+ @Module({
134
+ imports: [
135
+ EventEmitterModule.forRoot(),
136
+ WmsModuleGraphqlModule.forRootAsync({
137
+ ...coreOptions,
138
+ eventEmitterBridge: wmsEventEmitterBridgeProvider,
139
+ }),
140
+ ],
141
+ })
142
+ export class AppModule {}
143
+
144
+ // anywhere
145
+ @Injectable()
146
+ class StockSync {
147
+ @OnEvent('wms.stock.allocated')
148
+ handle(payload: WmsStockEventPayload) { /* ... */ }
149
+ }
150
+ ```
151
+
152
+ Every event is emitted as `wms.<event-name>` with its payload. `afterListeners`
153
+ and the bridge can be used together — both fire.
154
+
155
+ ---
156
+
157
+ ## 5. Complete event list
158
+
159
+ ### Operation events (38) — `<domain>.<past-tense-action>`
160
+
161
+ | Event | Mutation | Fans out to stock event |
162
+ |---|---|---|
163
+ | `receive-inventory-order.created` | createReceiveInventoryOrder | — |
164
+ | `receive-inventory-order.updated` | updateReceiveInventoryOrder | — |
165
+ | `receive-inventory-order.received` | receive | `stock.received` |
166
+ | `ship-inventory-order.created` | createShipInventoryOrder | — |
167
+ | `ship-inventory-order.updated` | updateShipInventoryOrder | `stock.in-shipment` |
168
+ | `ship-inventory-order.shipped` | ship | `stock.shipped` |
169
+ | `ship-inventory-order.ship-cancelled` | cancelShip | `stock.shipped` (negative) |
170
+ | `transfer-order.created` | createTransferOrder | `stock.in-transfer` |
171
+ | `transfer-order.updated` | updateTransferOrder | `stock.in-transfer` |
172
+ | `transfer-order.transferred` | transfer | `stock.transferred` |
173
+ | `transfer-order.transfer-cancelled` | cancelTransfer | `stock.in-transfer` (reversal) |
174
+ | `hold-inventory-order.created` | createHoldInventoryOrder | `stock.held` |
175
+ | `hold-inventory-order.updated` | updateHoldInventoryOrder | `stock.held` |
176
+ | `hold-inventory-order.released` | release | `stock.released` |
177
+ | `allocate-inventory-order.created` | createAllocateInventoryOrder | `stock.allocated` |
178
+ | `allocate-inventory-order.updated` | updateAllocateInventoryOrder | `stock.allocated` |
179
+ | `allocate-inventory-order.deallocated` | deallocate | `stock.deallocated` |
180
+ | `transfer-vendor-order.created` | createTransferVendorOrder | `stock.vendor-transferred` |
181
+ | `transfer-vendor-order.updated` | updateTransferVendorOrder | `stock.vendor-transferred` |
182
+ | `transfer-vendor-order.returned` | returnTransferVendorOrder | `stock.vendor-returned` |
183
+ | `transfer-customer-order.created` | createTransferCustomerOrder | `stock.customer-held` |
184
+ | `transfer-customer-order.updated` | updateTransferCustomerOrder | `stock.customer-held` |
185
+ | `transfer-customer-order.returned` | returnTransferCustomerOrder | `stock.customer-returned` |
186
+ | `quality-inspection-order.inspected` | inspectMaterials | `stock.inspection-resolved` |
187
+ | `scrape-order.scraped` | scrapeMaterials | `stock.scraped` |
188
+ | `reclassify-inventory-order.reclassified` | reclassifyInventoryOrder | `stock.reclassified` |
189
+ | `batch.merged` | mergeBatch | `stock.merged` |
190
+ | `batch.splitted` | splitBatch | `stock.splitted` |
191
+ | `material.created` | createMaterial | — |
192
+ | `material.updated` | updateMaterial | — |
193
+ | `loader.created` | createLoader | — |
194
+ | `loader.batch-created` | createBatchLoader | — |
195
+ | `loader.updated` | updateLoader | — |
196
+ | `loader.type-created` | createLoaderType | — |
197
+ | `loader.type-updated` | updateLoaderType | — |
198
+ | `location.created` | createLocation | — |
199
+ | `location.updated` | updateLocation | — |
200
+ | `warehouse-map.updated` | updateWarehouseMap | — |
201
+
202
+ > The "Fans out to stock event" column is **indicative**, not a hard binding. A
203
+ > mutation never names a stock event directly — `StockChangeSubscriber` derives
204
+ > it from the `*_order_change` table that was written and the sign of
205
+ > `quantityChanged`. So a mutation emits a stock event only on the code paths
206
+ > that actually write a change row (e.g. an `update*` only when it adds items).
207
+
208
+ ### Stock domain events (18) — `stock.<change-type>`
209
+
210
+ `stock.received`, `stock.in-shipment`, `stock.shipped`, `stock.in-transfer`,
211
+ `stock.transferred`, `stock.vendor-transferred`, `stock.vendor-returned`,
212
+ `stock.held`, `stock.released`, `stock.customer-held`,
213
+ `stock.customer-returned`, `stock.splitted`, `stock.merged`, `stock.allocated`,
214
+ `stock.deallocated`, `stock.reclassified`, `stock.scraped`,
215
+ `stock.inspection-resolved`.
216
+
217
+ The first 17 map 1:1 to `ChangeType`. `stock.inspection-resolved` has no
218
+ `ChangeType` (quality inspection only flips the `inspectionResult` flag) and
219
+ carries `changeType: null` plus `inspectionResult`.
220
+
221
+ A single operation can emit **several** stock events — one per `*_order_change`
222
+ row written, i.e. one per affected item. Cancellations re-use the forward event
223
+ with a negative `quantityDelta` (`cancelShip` -> `stock.shipped`,
224
+ `cancelTransfer` -> `stock.in-transfer`); branch on the sign, not the name only.
225
+
226
+ ---
227
+
228
+ ## 6. Payloads
229
+
230
+ **Operation event** — `WmsOperationEventPayload`:
231
+
232
+ ```ts
233
+ { id: string | null; // primary id of the operation's order/entity
234
+ result: unknown } // the mutation's return value (DTO, or boolean for actions)
235
+ ```
236
+
237
+ **Stock event** — `WmsStockEventPayload`:
238
+
239
+ ```ts
240
+ { orderId: string; // = change.id; correlation id with the operation event
241
+ itemId: string; // = change.itemId
242
+ quantityDelta: number; // = change.quantityChanged (sign = direction)
243
+ changeType: ChangeType | null; // null only for stock.inspection-resolved
244
+ inspectionResult?: boolean | null } // present only on stock.inspection-resolved
245
+ ```
246
+
247
+ `orderId` is the same on the operation event (`payload.id`) and the stock events
248
+ it fans out to, so listeners can correlate the two tiers. For richer
249
+ batch/material/location details, look them up by `orderId` / `itemId` inside the
250
+ after-listener — the data is committed by then.
251
+
252
+ ---
253
+
254
+ ## 7. Guarantees & limitations
255
+
256
+ - **Post-commit, in-order.** After-listeners fire strictly after commit, in
257
+ collection order, with the operation event last.
258
+ - **Veto is side-effect-free.** A throwing before-hook runs before any
259
+ transaction; nothing is written.
260
+ - **Listener isolation.** A throwing after-listener is logged and skipped; it
261
+ never rolls back the (already committed) mutation or blocks other listeners.
262
+ - **In-process, not persistent.** If the process crashes after commit but before
263
+ flush, those events are lost. This is an in-process event bus, not an
264
+ at-least-once outbox. If you need durable delivery, persist inside your
265
+ after-listener is *not* enough — use an external CDC/outbox.
266
+ - **"Handler resolve = post-commit" invariant.** Relies on every mutation
267
+ resolver `await`-ing all its writes (it does today). Never fire-and-forget an
268
+ un-awaited DB write inside a mutation.
269
+ - **Uneven transaction boundaries.** `receive*` wrap the transaction at the
270
+ resolver, `ship/transfer/cancel*/reclassify/merge/split` at the service, and
271
+ the `create/update` series have no explicit transaction (multiple awaits).
272
+ Events for the no-transaction operations fire after the writes complete, but
273
+ those operations were never atomic to begin with.
274
+
275
+ ---
276
+
277
+ ## 8. Public API
278
+
279
+ From the package root (`@rytass/wms-module-graphql`, re-exported from
280
+ `@rytass/wms-module-core`):
281
+
282
+ | Export | Kind | Purpose |
283
+ |---|---|---|
284
+ | `WmsMutationEvent({ event })` | decorator | Annotates a mutation (already applied across the library). |
285
+ | `WmsBeforeHookMap` / `WmsAfterListenerMap` | type | The `beforeHooks` / `afterListeners` option shapes. |
286
+ | `WmsEventModuleOptions` | type | `{ beforeHooks?, afterListeners? }` (part of `forRootAsync`). |
287
+ | `WmsBeforeHookContext` / `WmsAfterEventContext` | type | Hook/listener argument shapes. |
288
+ | `WmsDomainEvent` | type | `{ name, payload }`. |
289
+ | `WmsEventName` / `WmsOperationEventName` / `WmsStockEventName` | type | Event-name unions. |
290
+ | `WmsEventPayloadMap` | type | Event name -> payload mapping. |
291
+ | `WmsOperationEventPayload` / `WmsStockEventPayload` | type | Payload shapes. |
292
+
293
+ From the adapter subpath (`@rytass/wms-module-graphql/adapters/event-emitter`):
294
+
295
+ | Export | Kind | Purpose |
296
+ |---|---|---|
297
+ | `wmsEventEmitterBridgeProvider` | provider | Pass to `forRootAsync({ eventEmitterBridge })` to fan events onto `@nestjs/event-emitter`. |
298
+
299
+ `forRootAsync` accepts `beforeHooks`, `afterListeners` and `eventEmitterBridge`
300
+ on its single options object — all optional.
301
+
302
+ ---
303
+
304
+ See also: [Authorization](./authorization.md) · [API Reference](./api-reference.md) · [Integration Guide](./integration-guide.md)
@@ -16,7 +16,9 @@ Target audience: engineers integrating WMS into a new product who want a working
16
16
  5. [Frontend setup (Next.js Pages Router)](#5-frontend-setup-nextjs-pages-router)
17
17
  6. [Pages Router shell pattern (the 19 thin shells)](#6-pages-router-shell-pattern-the-19-thin-shells)
18
18
  7. [Apollo client wiring](#7-apollo-client-wiring)
19
- 8. [Authentication / authorization](#8-authentication--authorization)
19
+ 8. [Authentication, authorization & events](#8-authentication-authorization--events)
20
+ - [8.5 Backend authorization (resolver-level)](#85-backend-authorization-resolver-level)
21
+ - [8.6 Mutation events & hooks](#86-mutation-events--hooks)
20
22
  9. [Database support matrix](#9-database-support-matrix)
21
23
  10. [Seed data for demos](#10-seed-data-for-demos)
22
24
  11. [Deployment notes](#11-deployment-notes)
@@ -739,7 +741,7 @@ The full list of generated `Document` constants and their types is in
739
741
 
740
742
  ---
741
743
 
742
- ## 8. Authentication / authorization
744
+ ## 8. Authentication, authorization & events
743
745
 
744
746
  The lib ships **no frontend auth** — the page components assume they render in an authenticated context and call queries on mount without checking session state. On the **backend**, `@rytass/wms-module-graphql` ships an *opt-in, host-neutral authorization protocol* you wire to your own permission system (member-base / Casbin / JWT / anything). See [§8.5 Backend authorization](#85-backend-authorization-resolver-level).
745
747
 
@@ -869,6 +871,85 @@ The WMS `resource`/`action` strings are **internal metadata only** — they are
869
871
 
870
872
  ---
871
873
 
874
+ ### 8.6 Mutation events & hooks
875
+
876
+ > 📖 Condensed version. The full design, the complete list of 38 operation + 18 stock domain events, payloads, and limitations live in [**`docs/events.md`**](./events.md).
877
+
878
+ Every WMS mutation can be **intervened in** and **observed** through two opt-in hook points wired on the same `WmsModuleGraphqlModule.forRootAsync({ ... })` call as everything else:
879
+
880
+ - **`beforeHooks`** run *before any DB work* — `throw` to **veto** the mutation (zero side effects).
881
+ - **`afterListeners`** run *after the outermost transaction commits* — you are never notified about a rolled-back change.
882
+
883
+ ```ts
884
+ // apps/api/src/app/app.module.ts
885
+ import { ForbiddenException } from '@nestjs/common';
886
+ import { WmsModuleGraphqlModule } from '@rytass/wms-module-graphql';
887
+
888
+ WmsModuleGraphqlModule.forRootAsync({
889
+ // ...the same core options as §4.2 (defaultLoaderSerialId, useFactory, etc.)...
890
+
891
+ beforeHooks: {
892
+ // intent payload = the raw GraphQL args; throw to abort
893
+ 'ship-inventory-order.shipped': async ({ args, executionContext }) => {
894
+ if (!(await canShip(args, executionContext))) {
895
+ throw new ForbiddenException('shipment blocked');
896
+ }
897
+ },
898
+ },
899
+
900
+ afterListeners: {
901
+ // operation event — fires once per mutation, with the result
902
+ 'ship-inventory-order.shipped': async ({ payload }) => {
903
+ await erp.markShipped(payload.id, payload.result);
904
+ },
905
+ // stock domain event — fires once per affected item (post-commit)
906
+ 'stock.allocated': async ({ payload }) => {
907
+ await erp.reserve(payload.orderId, payload.itemId, payload.quantityDelta);
908
+ },
909
+ },
910
+ });
911
+ ```
912
+
913
+ Two tiers of events you can listen to:
914
+
915
+ - **Operation events** — one per mutation: `material.created`, `receive-inventory-order.received`, `ship-inventory-order.shipped`, … (38 total). Payload: `{ id, result }`.
916
+ - **Stock domain events** — driven by the `ChangeType` state machine: `stock.allocated`, `stock.shipped`, `stock.held`, … (18 total). Payload: `{ orderId, itemId, quantityDelta, changeType, inspectionResult? }`. `orderId` matches the operation event's `id`, so you can correlate the two tiers.
917
+
918
+ Both maps are fully typed — the payload is inferred from the event-name key. With neither configured, the interceptor passes through with **zero overhead** (no behaviour change).
919
+
920
+ #### Optional: `@OnEvent` via `@nestjs/event-emitter`
921
+
922
+ Prefer decoupled subscribers? Install `@nestjs/event-emitter` (an optional peer dependency) and opt into the bridge — every event is then also emitted as `wms.<event-name>`:
923
+
924
+ ```ts
925
+ import { EventEmitterModule } from '@nestjs/event-emitter';
926
+ import { wmsEventEmitterBridgeProvider } from '@rytass/wms-module-graphql/adapters/event-emitter';
927
+
928
+ @Module({
929
+ imports: [
930
+ EventEmitterModule.forRoot(),
931
+ WmsModuleGraphqlModule.forRootAsync({
932
+ ...coreOptions,
933
+ eventEmitterBridge: wmsEventEmitterBridgeProvider, // <- enables the bridge
934
+ }),
935
+ ],
936
+ })
937
+ export class AppModule {}
938
+
939
+ // elsewhere
940
+ @Injectable()
941
+ class StockSync {
942
+ @OnEvent('wms.stock.allocated')
943
+ handle(payload: WmsStockEventPayload) { /* ... */ }
944
+ }
945
+ ```
946
+
947
+ The bridge is the only thing that imports `@nestjs/event-emitter`, so it stays an entirely optional dependency — the core module never requires it. `afterListeners` and the bridge can be used together.
948
+
949
+ > ⚠️ Delivery is **in-process and post-commit**, not a durable outbox: if the process crashes after commit but before dispatch, those events are lost. See [`docs/events.md` §7](./events.md#7-guarantees--limitations) for the full guarantees.
950
+
951
+ ---
952
+
872
953
  ## 9. Database support matrix
873
954
 
874
955
  | Database | Status | Notes |