@rytass/wms-module-graphql 0.4.0 → 0.6.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 (40) hide show
  1. package/README.md +56 -3
  2. package/adapters/event-emitter.d.ts +29 -0
  3. package/adapters/event-emitter.js +41 -0
  4. package/adapters/member-base.d.ts +11 -0
  5. package/adapters/member-base.js +17 -1
  6. package/docs/README.md +3 -0
  7. package/docs/api-reference.md +60 -0
  8. package/docs/events.md +500 -0
  9. package/docs/integration-guide.md +111 -2
  10. package/index.cjs.js +154 -1
  11. package/index.d.ts +2 -0
  12. package/index.js +3 -0
  13. package/lib/events/index.d.ts +5 -0
  14. package/lib/events/operation-result-map.d.ts +77 -0
  15. package/lib/events/typed-maps.d.ts +73 -0
  16. package/lib/events/wms-mutation-event.constants.d.ts +12 -0
  17. package/lib/events/wms-mutation-event.constants.js +4 -0
  18. package/lib/events/wms-mutation-event.decorator.d.ts +15 -0
  19. package/lib/events/wms-mutation-event.decorator.js +20 -0
  20. package/lib/events/wms-mutation.interceptor.d.ts +21 -0
  21. package/lib/events/wms-mutation.interceptor.js +90 -0
  22. package/lib/graphql.module.d.ts +24 -5
  23. package/lib/graphql.module.js +16 -2
  24. package/lib/mutations/allocate-inventory-order.mutation.js +5 -0
  25. package/lib/mutations/hold-inventory-order.mutation.js +5 -0
  26. package/lib/mutations/loader.mutation.js +7 -0
  27. package/lib/mutations/location.mutation.js +4 -0
  28. package/lib/mutations/material.mutation.js +4 -0
  29. package/lib/mutations/merge-batch.mutation.js +3 -0
  30. package/lib/mutations/quality-inspection-order.mutation.js +3 -0
  31. package/lib/mutations/receive-inventory-order.mutation.js +5 -0
  32. package/lib/mutations/reclassify-inventory-order.mutation.js +3 -0
  33. package/lib/mutations/scrape-order.mutation.js +3 -0
  34. package/lib/mutations/ship-inventory-order.mutation.js +6 -0
  35. package/lib/mutations/split-batch.mutation.js +3 -0
  36. package/lib/mutations/transfer-customer-order.mutation.js +5 -0
  37. package/lib/mutations/transfer-order.mutation.js +6 -0
  38. package/lib/mutations/transfer-vendor-order.mutation.js +5 -0
  39. package/lib/mutations/warehouse-map.mutation.js +3 -0
  40. package/package.json +13 -3
package/docs/events.md ADDED
@@ -0,0 +1,500 @@
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
+ ### v2 payload enrichment (upcoming minor)
40
+
41
+ Operation event payloads will gain two new fields — `args` (the GraphQL input
42
+ that triggered the mutation) and `actor` (who performed it, obtained via an
43
+ injectable `actorExtractor`) — turning the shape from `{ id, result }` into
44
+ `{ id, result, args, actor }`. The `result` field will also become **per-event
45
+ typed** via `WmsOperationResultMap` so subscribers receive the concrete DTO
46
+ instead of `unknown`. Nine action mutations (`received`, `deallocated`, …) still
47
+ return `boolean`; their real information comes from `args` and the stock events
48
+ they fan out.
49
+
50
+ Stock event payloads gain an opt-in `enrichStockEvents: true` flag on
51
+ `forRootAsync` that appends `materialId / batchId / locationId / loaderId` (one
52
+ indexed PK lookup per event, same transaction). Without the flag the lazy shape
53
+ is unchanged. See [§6 Payloads](#6-payloads) for the full shapes and the
54
+ split/merge/reclassify partial-enrichment caveat.
55
+
56
+ ---
57
+
58
+ ## 2. Architecture — post-commit guarantee
59
+
60
+ The hard requirement is that an after-listener must **never** be told about a
61
+ change that was rolled back. WMS guarantees this with an `AsyncLocalStorage`
62
+ dispatch scope opened by a per-mutation interceptor:
63
+
64
+ ```
65
+ GraphQL Mutation (@WmsMutationEvent)
66
+ |
67
+ v
68
+ WmsMutationInterceptor
69
+ 1. beforeHook(event, args) --throw--> VETO (no transaction, clean error)
70
+ 2. als.run(scope, async () => {
71
+ result = await handler() // services open tx, write *_order_change rows
72
+ }) // -> StockChangeSubscriber.afterInsert
73
+ | // collects stock.* events into the scope
74
+ | handler resolved == transaction committed
75
+ v
76
+ 3. flush(scope.buffer)
77
+ afterListener(event) (isolated; one failure never blocks others)
78
+ emitterBridge.forward(event) --> EventEmitter2 (optional)
79
+ ```
80
+
81
+ Why it is post-commit: TypeORM's `dataSource.transaction(cb)` resolves only
82
+ *after* `COMMIT`. The interceptor awaits the whole handler before flushing, so
83
+ the buffer is dispatched strictly after commit. If the transaction rolls back
84
+ the handler rejects, the interceptor never flushes, and the buffered events are
85
+ discarded.
86
+
87
+ Stock events are collected centrally by a single TypeORM subscriber
88
+ (`StockChangeSubscriber`) that listens to every `*_order_change` insert and
89
+ derives the `ChangeType` from the table name and the sign of `quantityChanged`.
90
+ The 12 domain services are untouched, and any future code path that writes a
91
+ change row is covered automatically.
92
+
93
+ ---
94
+
95
+ ## 3. Integration
96
+
97
+ `beforeHooks` / `afterListeners` are typed maps keyed by event name; the payload
98
+ is inferred from the key.
99
+
100
+ ```ts
101
+ import { WmsModuleGraphqlModule } from '@rytass/wms-module-graphql';
102
+ import { memberBaseActorExtractor } from '@rytass/wms-module-graphql/adapters/member-base';
103
+ import { ForbiddenException } from '@nestjs/common';
104
+
105
+ WmsModuleGraphqlModule.forRootAsync({
106
+ useFactory: async () => ({}),
107
+ defaultLoaderSerialId: 'DEFAULT',
108
+ defaultLoaderTypeKey: 'DEFAULT',
109
+ defaultLocationKey: 'DEFAULT',
110
+
111
+ // (v2) extract the actor from the request; member-base reads req.payload → { id, domain }
112
+ actorExtractor: memberBaseActorExtractor,
113
+
114
+ // (v2) append materialId/batchId/locationId/loaderId to stock event payloads
115
+ enrichStockEvents: true,
116
+
117
+ // intervene — runs before any DB work; throw to veto
118
+ beforeHooks: {
119
+ 'ship-inventory-order.shipped': async ({ args, executionContext }) => {
120
+ if (!(await canShip(args, executionContext))) {
121
+ throw new ForbiddenException('shipment blocked');
122
+ }
123
+ },
124
+ },
125
+
126
+ // notify — runs after commit
127
+ afterListeners: {
128
+ // v2: payload.args carries the items[] input; payload.actor carries { id, domain }
129
+ 'ship-inventory-order.shipped': async ({ payload }) => {
130
+ await erp.markShipped(payload.id, payload.result, payload.actor);
131
+ },
132
+ 'stock.allocated': async ({ payload }) => {
133
+ // v2 with enrichStockEvents: materialId/batchId/locationId/loaderId also available
134
+ await erp.reserve(payload.orderId, payload.itemId, payload.quantityDelta);
135
+ },
136
+ },
137
+ });
138
+ ```
139
+
140
+ - A `beforeHook` receives `{ event, args, executionContext }` — the mutation
141
+ **intent** (raw GraphQL args), since no result exists yet. Throw (e.g. a
142
+ `ForbiddenException`) to abort with zero side effects.
143
+ - An `afterListener` receives `{ event, payload }` — the committed result (see
144
+ [§6](#6-payloads)). It may be async; a thrown error is logged and isolated, it
145
+ never affects the committed mutation or other listeners.
146
+ - **(v2)** `payload.args` carries the full GraphQL input that triggered the mutation
147
+ — particularly useful for the 9 boolean action events whose `result` is
148
+ `true`/`false` only.
149
+ - **(v2)** `payload.actor` is `WmsActor | null` — populated when `actorExtractor`
150
+ is configured, `null` otherwise. Shape: `{ id: string; domain?: string; raw?:
151
+ unknown }`. The `memberBaseActorExtractor` helper (exported from the
152
+ `@rytass/wms-module-graphql/adapters/member-base` subpath) reads
153
+ `req.payload` and returns `{ id, domain }`.
154
+
155
+ ### 3.1 The 9 boolean action events
156
+
157
+ Nine mutations return `boolean` and cannot carry a rich DTO `result` without a
158
+ breaking GraphQL schema change. For subscribers, the meaningful signal is:
159
+
160
+ 1. **`args`** — the exact items input (see §5 for each event's args shape).
161
+ 2. **Stock events** — the operation fans out one `stock.*` event per affected item
162
+ (see the "Fans out to stock event" column in §5).
163
+
164
+ The operation event itself only confirms success (`result: true`).
165
+
166
+ | Event | args key items shape |
167
+ |------------------------------------------|--------------------------------------------------------------|
168
+ | `receive-inventory-order.received` | `{ itemId, quantity, batchKey?, locationKey?, loaderKey?, inspectionResult? }` |
169
+ | `ship-inventory-order.shipped` | `{ itemId, quantity }` |
170
+ | `ship-inventory-order.ship-cancelled` | `{ itemId, quantity? }` |
171
+ | `transfer-order.transferred` | `{ itemId, quantity }` |
172
+ | `transfer-order.transfer-cancelled` | `{ itemId, quantity? }` |
173
+ | `hold-inventory-order.released` | `{ aggregatedKey, quantity, unitId? }` |
174
+ | `allocate-inventory-order.deallocated` | `{ aggregatedKey, quantity, unitId? }` |
175
+ | `transfer-customer-order.returned` | `{ aggregatedKey, quantity, unitId?, locationKey, loaderKey? }` |
176
+ | `transfer-vendor-order.returned` | `{ aggregatedKey, quantity, unitId?, locationKey, loaderKey? }` |
177
+
178
+ All are passed as `@Args('items', …) items: T[]`, so `args.items` is the array.
179
+
180
+ ---
181
+
182
+ ## 4. The `@nestjs/event-emitter` bridge
183
+
184
+ Prefer decoupled `@OnEvent` subscribers? Opt into the bridge. It is the only
185
+ thing that imports `@nestjs/event-emitter` (an **optional** peer dependency), so
186
+ the core module never depends on it.
187
+
188
+ ```ts
189
+ import { EventEmitterModule } from '@nestjs/event-emitter';
190
+ import { wmsEventEmitterBridgeProvider } from '@rytass/wms-module-graphql/adapters/event-emitter';
191
+
192
+ @Module({
193
+ imports: [
194
+ EventEmitterModule.forRoot(),
195
+ WmsModuleGraphqlModule.forRootAsync({
196
+ ...coreOptions,
197
+ eventEmitterBridge: wmsEventEmitterBridgeProvider,
198
+ }),
199
+ ],
200
+ })
201
+ export class AppModule {}
202
+
203
+ // anywhere
204
+ @Injectable()
205
+ class StockSync {
206
+ @OnEvent('wms.stock.allocated')
207
+ handle(payload: WmsStockEventPayload) { /* ... */ }
208
+ }
209
+ ```
210
+
211
+ Every event is emitted as `wms.<event-name>` with its payload. `afterListeners`
212
+ and the bridge can be used together — both fire.
213
+
214
+ ---
215
+
216
+ ## 5. Complete event list
217
+
218
+ ### Operation events (38) — `<domain>.<past-tense-action>`
219
+
220
+ The **v2 `result` type** column shows the concrete type subscribers receive via
221
+ `WmsOperationResultMap`. Events marked ⚠️ **boolean** are the 9 action mutations
222
+ whose `result` is always `true`/`false`; their real signal is `args` + the stock
223
+ events they fan out (see §3.1).
224
+
225
+ | Event | Mutation | v2 `result` type | Fans out to stock event |
226
+ |---------------------------------------------|-------------------------------|-----------------------------------|-------------------------------|
227
+ | `receive-inventory-order.created` | createReceiveInventoryOrder | `ReceiveInventoryOrderDto` | — |
228
+ | `receive-inventory-order.updated` | updateReceiveInventoryOrder | `ReceiveInventoryOrderDto` | — |
229
+ | `receive-inventory-order.received` | receive | ⚠️ `boolean` | `stock.received` |
230
+ | `ship-inventory-order.created` | createShipInventoryOrder | `ShipInventoryOrderDto` | — |
231
+ | `ship-inventory-order.updated` | updateShipInventoryOrder | `ShipInventoryOrderDto` | `stock.in-shipment` |
232
+ | `ship-inventory-order.shipped` | ship | ⚠️ `boolean` | `stock.shipped` |
233
+ | `ship-inventory-order.ship-cancelled` | cancelShip | ⚠️ `boolean` | `stock.shipped` (negative) |
234
+ | `transfer-order.created` | createTransferOrder | `TransferOrderDto` | `stock.in-transfer` |
235
+ | `transfer-order.updated` | updateTransferOrder | `TransferOrderDto` | `stock.in-transfer` |
236
+ | `transfer-order.transferred` | transfer | ⚠️ `boolean` | `stock.transferred` |
237
+ | `transfer-order.transfer-cancelled` | cancelTransfer | ⚠️ `boolean` | `stock.in-transfer` (reversal)|
238
+ | `hold-inventory-order.created` | createHoldInventoryOrder | `HoldInventoryOrderDto` | `stock.held` |
239
+ | `hold-inventory-order.updated` | updateHoldInventoryOrder | `HoldInventoryOrderDto` | `stock.held` |
240
+ | `hold-inventory-order.released` | release | ⚠️ `boolean` | `stock.released` |
241
+ | `allocate-inventory-order.created` | createAllocateInventoryOrder | `AllocateInventoryOrderDto` | `stock.allocated` |
242
+ | `allocate-inventory-order.updated` | updateAllocateInventoryOrder | `AllocateInventoryOrderDto` | `stock.allocated` |
243
+ | `allocate-inventory-order.deallocated` | deallocate | ⚠️ `boolean` | `stock.deallocated` |
244
+ | `transfer-vendor-order.created` | createTransferVendorOrder | `TransferVendorOrderDto` | `stock.vendor-transferred` |
245
+ | `transfer-vendor-order.updated` | updateTransferVendorOrder | `TransferVendorOrderDto` | `stock.vendor-transferred` |
246
+ | `transfer-vendor-order.returned` | returnTransferVendorOrder | ⚠️ `boolean` | `stock.vendor-returned` |
247
+ | `transfer-customer-order.created` | createTransferCustomerOrder | `TransferCustomerOrderDto` | `stock.customer-held` |
248
+ | `transfer-customer-order.updated` | updateTransferCustomerOrder | `TransferCustomerOrderDto` | `stock.customer-held` |
249
+ | `transfer-customer-order.returned` | returnTransferCustomerOrder | ⚠️ `boolean` | `stock.customer-returned` |
250
+ | `quality-inspection-order.inspected` | inspectMaterials | `QualityInspectionOrderDto` | `stock.inspection-resolved` |
251
+ | `scrape-order.scraped` | scrapeMaterials | `ScrapeOrderDto` | `stock.scraped` |
252
+ | `reclassify-inventory-order.reclassified` | reclassifyInventoryOrder | `ReclassifyInventoryOrderDto` | `stock.reclassified` |
253
+ | `batch.merged` | mergeBatch | `MergeBatchOrderDto` | `stock.merged` |
254
+ | `batch.splitted` | splitBatch | `SplitBatchOrderDto` | `stock.splitted` |
255
+ | `material.created` | createMaterial | `MaterialDto` | — |
256
+ | `material.updated` | updateMaterial | `MaterialDto` | — |
257
+ | `loader.created` | createLoader | `LoaderDto[]` | — |
258
+ | `loader.batch-created` | createBatchLoader | `LoaderDto[]` | — |
259
+ | `loader.updated` | updateLoader | `LoaderDto` | — |
260
+ | `loader.type-created` | createLoaderType | `LoaderTypeDto` | — |
261
+ | `loader.type-updated` | updateLoaderType | `LoaderTypeDto` | — |
262
+ | `location.created` | createLocation | `LocationDto` | — |
263
+ | `location.updated` | updateLocation | `LocationDto` | — |
264
+ | `warehouse-map.updated` | updateWarehouseMap | `MapDataDto` | — |
265
+
266
+ > The "Fans out to stock event" column is **indicative**, not a hard binding. A
267
+ > mutation never names a stock event directly — `StockChangeSubscriber` derives
268
+ > it from the `*_order_change` table that was written and the sign of
269
+ > `quantityChanged`. So a mutation emits a stock event only on the code paths
270
+ > that actually write a change row (e.g. an `update*` only when it adds items).
271
+ >
272
+ > The "v2 `result` type" column refers to the typed overlay provided by
273
+ > `WmsOperationResultMap` (exported from `@rytass/wms-module-graphql`). In v1
274
+ > `result` is typed `unknown`; consumer code using the typed maps gets the
275
+ > concrete DTO automatically.
276
+
277
+ ### Stock domain events (18) — `stock.<change-type>`
278
+
279
+ `stock.received`, `stock.in-shipment`, `stock.shipped`, `stock.in-transfer`,
280
+ `stock.transferred`, `stock.vendor-transferred`, `stock.vendor-returned`,
281
+ `stock.held`, `stock.released`, `stock.customer-held`,
282
+ `stock.customer-returned`, `stock.splitted`, `stock.merged`, `stock.allocated`,
283
+ `stock.deallocated`, `stock.reclassified`, `stock.scraped`,
284
+ `stock.inspection-resolved`.
285
+
286
+ The first 17 map 1:1 to `ChangeType`. `stock.inspection-resolved` has no
287
+ `ChangeType` (quality inspection only flips the `inspectionResult` flag) and
288
+ carries `changeType: null` plus `inspectionResult`.
289
+
290
+ A single operation can emit **several** stock events — one per `*_order_change`
291
+ row written, i.e. one per affected item. Cancellations re-use the forward event
292
+ with a negative `quantityDelta` (`cancelShip` -> `stock.shipped`,
293
+ `cancelTransfer` -> `stock.in-transfer`); branch on the sign, not the name only.
294
+
295
+ ---
296
+
297
+ ## 6. Payloads
298
+
299
+ ### Operation event — `WmsOperationEventPayload`
300
+
301
+ **v1 shape** (currently shipped):
302
+
303
+ ```ts
304
+ {
305
+ id: string | null; // primary id of the operation's order/entity
306
+ result: unknown; // the mutation's return value (DTO, or boolean for actions)
307
+ }
308
+ ```
309
+
310
+ **v2 shape** (upcoming minor — `core@0.3.0` / `graphql@0.6.0`):
311
+
312
+ ```ts
313
+ {
314
+ readonly id: string | null; // (unchanged) primary id
315
+ readonly result: TResult; // now per-event typed via WmsOperationResultMap
316
+ readonly args: Readonly<Record<string, unknown>>; // NEW — the GraphQL input (intent)
317
+ readonly actor: WmsActor | null; // NEW — who performed the mutation
318
+ }
319
+ ```
320
+
321
+ **`WmsActor`** shape:
322
+
323
+ ```ts
324
+ interface WmsActor {
325
+ readonly id: string; // subject identifier
326
+ readonly domain?: string; // optional domain / tenant discriminator
327
+ readonly raw?: unknown; // host-specific extras; opaque to the lib
328
+ }
329
+ ```
330
+
331
+ `actor` is `null` when no `actorExtractor` is configured. The
332
+ `memberBaseActorExtractor` helper (from
333
+ `@rytass/wms-module-graphql/adapters/member-base`) reads `req.payload` and
334
+ returns `{ id, domain }` — the same fields `CasbinGuard` already puts on the
335
+ JWT payload.
336
+
337
+ `WmsBeforeHookContext` also gains `actor` (so before-hooks can veto by identity):
338
+
339
+ ```ts
340
+ interface WmsBeforeHookContext<N extends WmsEventName> {
341
+ event: N;
342
+ args: Record<string, unknown>;
343
+ executionContext: ExecutionContext;
344
+ actor: WmsActor | null; // v2 addition
345
+ }
346
+ ```
347
+
348
+ ### Typed result overlay — `WmsOperationResultMap`
349
+
350
+ The v2 graphql package exports a `WmsOperationResultMap` interface that maps
351
+ every operation event name to its concrete `result` type. Consumers who use the
352
+ typed `beforeHooks` / `afterListeners` maps automatically receive the resolved
353
+ DTO instead of `unknown`:
354
+
355
+ ```ts
356
+ // listener receives ShipInventoryOrderDto, not unknown
357
+ afterListeners: {
358
+ 'ship-inventory-order.updated': async ({ payload }) => {
359
+ console.log(payload.result.id); // typed as string
360
+ },
361
+ // listener receives boolean
362
+ 'ship-inventory-order.shipped': async ({ payload }) => {
363
+ console.log(payload.result); // typed as boolean
364
+ console.log(payload.args); // { items: [{ itemId, quantity }, ...] }
365
+ console.log(payload.actor?.id); // actor id if actorExtractor is set
366
+ },
367
+ }
368
+ ```
369
+
370
+ ---
371
+
372
+ ### Stock event — `WmsStockEventPayload`
373
+
374
+ **Lazy shape** (default — unchanged):
375
+
376
+ ```ts
377
+ {
378
+ readonly orderId: string; // = change.id; correlation id with the operation event
379
+ readonly itemId: string; // = change.itemId
380
+ readonly quantityDelta: number; // = change.quantityChanged (sign = direction)
381
+ readonly changeType: ChangeType | null; // null only for stock.inspection-resolved
382
+ readonly inspectionResult?: boolean | null; // present only on stock.inspection-resolved
383
+ }
384
+ ```
385
+
386
+ **Enriched shape** (opt-in, `enrichStockEvents: true`):
387
+
388
+ The following fields are appended when the flag is enabled. They are loaded in
389
+ the **same transaction** via a single indexed PK lookup on the change's item row:
390
+
391
+ ```ts
392
+ readonly materialId?: string | null;
393
+ readonly batchId?: string | null;
394
+ readonly locationId?: string | null;
395
+ readonly loaderId?: string | null;
396
+ ```
397
+
398
+ > All four fields are optional/nullable — a `null` value means the column is
399
+ > nullable in the underlying order-item row and happened to be null at the time
400
+ > of the change. On any lookup error the system degrades to the lazy payload.
401
+
402
+ **Per-domain enrichment completeness** (`enrichStockEvents: true`):
403
+
404
+ | Domain (stock event) | materialId | batchId | locationId | loaderId |
405
+ |---------------------------------------------------|:----------:|:-------:|:----------:|:--------:|
406
+ | receive (`stock.received`) | yes | yes * | yes * | yes * |
407
+ | ship (`stock.in-shipment` / `stock.shipped`) | yes | yes | yes * | yes * |
408
+ | transfer-order (`stock.in-transfer` / `.transferred`) | yes | yes | yes | yes * |
409
+ | transfer-vendor (`stock.vendor-*`) | yes | yes | yes * | yes * |
410
+ | transfer-customer (`stock.customer-*`) | yes | yes | yes * | yes * |
411
+ | hold (`stock.held` / `stock.released`) | yes | yes | yes * | yes * |
412
+ | allocate (`stock.allocated` / `.deallocated`) | yes | yes | yes * | yes * |
413
+ | scrape (`stock.scraped`) | yes | yes | yes * | yes * |
414
+ | quality-inspection (`stock.inspection-resolved`) | yes | yes | yes * | yes * |
415
+ | **split (`stock.splitted`)** | **no** | **no** | yes * | yes * |
416
+ | **merge (`stock.merged`)** | **no** | **no** | yes | yes * |
417
+ | **reclassify (`stock.reclassified`)** | **no** | **no** | yes | yes * |
418
+
419
+ `*` = column is nullable; may be `null` even when enrichment is on.
420
+
421
+ > **Important**: split / merge / reclassify order-items have **no `materialId` /
422
+ > `batchId`** — those domains operate at batch-aggregation level and keep that
423
+ > metadata on the parent order, not the item. With `enrichStockEvents: true`
424
+ > these three event types only receive `locationId` / `loaderId`. If you need
425
+ > `materialId` / `batchId` for these domains, look them up from the parent order
426
+ > by `orderId` inside your after-listener.
427
+
428
+ ---
429
+
430
+ `orderId` is the same on the operation event (`payload.id`) and the stock events
431
+ it fans out to, so listeners can correlate the two tiers. For richer
432
+ batch/material/location details when `enrichStockEvents` is off, look them up by
433
+ `orderId` / `itemId` inside the after-listener — the data is committed by then.
434
+
435
+ ---
436
+
437
+ ## 7. Guarantees & limitations
438
+
439
+ - **Post-commit, in-order.** After-listeners fire strictly after commit, in
440
+ collection order, with the operation event last.
441
+ - **Veto is side-effect-free.** A throwing before-hook runs before any
442
+ transaction; nothing is written.
443
+ - **Listener isolation.** A throwing after-listener is logged and skipped; it
444
+ never rolls back the (already committed) mutation or blocks other listeners.
445
+ - **In-process, not persistent.** If the process crashes after commit but before
446
+ flush, those events are lost. This is an in-process event bus, not an
447
+ at-least-once outbox. If you need durable delivery, persist inside your
448
+ after-listener is *not* enough — use an external CDC/outbox.
449
+ - **"Handler resolve = post-commit" invariant.** Relies on every mutation
450
+ resolver `await`-ing all its writes (it does today). Never fire-and-forget an
451
+ un-awaited DB write inside a mutation.
452
+ - **Uneven transaction boundaries.** `receive*` wrap the transaction at the
453
+ resolver, `ship/transfer/cancel*/reclassify/merge/split` at the service, and
454
+ the `create/update` series have no explicit transaction (multiple awaits).
455
+ Events for the no-transaction operations fire after the writes complete, but
456
+ those operations were never atomic to begin with.
457
+
458
+ ---
459
+
460
+ ## 8. Public API
461
+
462
+ From the package root (`@rytass/wms-module-graphql`, re-exported from
463
+ `@rytass/wms-module-core`):
464
+
465
+ | Export | Kind | Purpose |
466
+ |-------------------------------------------------------------------|-----------|--------------------------------------------------------------------------------------------------------------|
467
+ | `WmsMutationEvent({ event })` | decorator | Annotates a mutation (already applied across the library). |
468
+ | `WmsBeforeHookMap` / `WmsAfterListenerMap` | type | The `beforeHooks` / `afterListeners` option shapes. |
469
+ | `WmsEventModuleOptions` | type | `{ beforeHooks?, afterListeners?, actorExtractor?, enrichStockEvents? }` (part of `forRootAsync`). |
470
+ | `WmsBeforeHookContext` / `WmsAfterEventContext` | type | Hook/listener argument shapes (v2: `WmsBeforeHookContext` gains `actor`). |
471
+ | `WmsDomainEvent` | type | `{ name, payload }`. |
472
+ | `WmsEventName` / `WmsOperationEventName` / `WmsStockEventName` | type | Event-name unions. |
473
+ | `WmsEventPayloadMap` | type | Event name -> payload mapping. |
474
+ | `WmsOperationEventPayload` | type | `{ id, result, args, actor }` (v2); `{ id, result }` in v1. |
475
+ | `WmsStockEventPayload` | type | `{ orderId, itemId, quantityDelta, changeType, inspectionResult? }` + optional enrich fields (v2). |
476
+ | `WmsActor` | type | `{ id: string; domain?: string; raw?: unknown }` — actor identity shape (v2). |
477
+ | `WmsActorExtractor` | type | `(context: ExecutionContext) => WmsActor \| null` — injected via `forRootAsync({ actorExtractor })` (v2). |
478
+ | `WmsOperationResultMap` | type | Event name → concrete `result` DTO mapping; enables per-event typed listeners (v2, lives in graphql lib). |
479
+
480
+ From the adapter subpath (`@rytass/wms-module-graphql/adapters/event-emitter`):
481
+
482
+ | Export | Kind | Purpose |
483
+ |-------------------------------|----------|-------------------------------------------------------------------------------------------------------|
484
+ | `wmsEventEmitterBridgeProvider` | provider | Pass to `forRootAsync({ eventEmitterBridge })` to fan events onto `@nestjs/event-emitter`. |
485
+
486
+ From the adapter subpath (`@rytass/wms-module-graphql/adapters/member-base`):
487
+
488
+ | Export | Kind | Purpose |
489
+ |-----------------------------|-----------|-----------------------------------------------------------------------------------------------------------|
490
+ | `memberBaseActorExtractor` | extractor | `WmsActorExtractor` that reads `req.payload` → `{ id, domain }` (v2). Pass to `forRootAsync({ actorExtractor })`. |
491
+
492
+ `forRootAsync` accepts `beforeHooks`, `afterListeners`, `eventEmitterBridge`,
493
+ `actorExtractor`, and `enrichStockEvents` on its single options object — all
494
+ optional. Omitting `actorExtractor` leaves `actor: null` on every payload;
495
+ omitting `enrichStockEvents` (or setting it `false`) keeps the lazy stock
496
+ payload shape.
497
+
498
+ ---
499
+
500
+ 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,113 @@ 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
+ import { memberBaseActorExtractor } from '@rytass/wms-module-graphql/adapters/member-base';
888
+
889
+ WmsModuleGraphqlModule.forRootAsync({
890
+ // ...the same core options as §4.2 (defaultLoaderSerialId, useFactory, etc.)...
891
+
892
+ // (v2) extract actor from the request: memberBaseActorExtractor reads
893
+ // req.payload → { id, domain } (the same JWT payload CasbinGuard uses)
894
+ actorExtractor: memberBaseActorExtractor,
895
+
896
+ // (v2) append materialId/batchId/locationId/loaderId to stock event payloads
897
+ // via a single indexed PK lookup per event, inside the same transaction
898
+ enrichStockEvents: true,
899
+
900
+ beforeHooks: {
901
+ // intent payload = the raw GraphQL args; v2 also receives actor
902
+ 'ship-inventory-order.shipped': async ({ args, actor, executionContext }) => {
903
+ if (!(await canShip(args, actor, executionContext))) {
904
+ throw new ForbiddenException('shipment blocked');
905
+ }
906
+ },
907
+ },
908
+
909
+ afterListeners: {
910
+ // operation event — fires once per mutation, with the result
911
+ // v2: payload also carries args (the items[] input) and actor
912
+ 'ship-inventory-order.shipped': async ({ payload }) => {
913
+ await erp.markShipped(payload.id, payload.result, payload.actor);
914
+ },
915
+ // stock domain event — fires once per affected item (post-commit)
916
+ // v2 with enrichStockEvents: materialId/batchId/locationId/loaderId also present
917
+ 'stock.allocated': async ({ payload }) => {
918
+ await erp.reserve(payload.orderId, payload.itemId, payload.quantityDelta);
919
+ },
920
+ },
921
+ });
922
+ ```
923
+
924
+ Two tiers of events you can listen to:
925
+
926
+ - **Operation events** — one per mutation: `material.created`, `receive-inventory-order.received`, `ship-inventory-order.shipped`, … (38 total). v1 payload: `{ id, result }`. v2 payload: `{ id, result, args, actor }`.
927
+ - **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. With `enrichStockEvents: true` the payload also carries `materialId?`, `batchId?`, `locationId?`, `loaderId?`.
928
+
929
+ 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).
930
+
931
+ #### v2: The 9 boolean action events
932
+
933
+ Nine mutations — `received`, `deallocated`, `released`, `shipped`, `ship-cancelled`,
934
+ `transferred`, `transfer-cancelled`, customer/vendor `returned` — return `boolean` and
935
+ cannot carry a richer `result` without a breaking GraphQL schema change. For subscribers,
936
+ the full signal comes from **`payload.args`** (the exact items input) and the **stock events
937
+ they fan out** (one per affected item). The operation event confirms success; `args` carries
938
+ the intent.
939
+
940
+ > ⚠️ **`enrichStockEvents` partial coverage**: split / merge / reclassify order-items have
941
+ > **no `materialId` / `batchId`** at the item level (these domains keep that metadata on the
942
+ > parent order). With `enrichStockEvents: true`, `stock.splitted`, `stock.merged`, and
943
+ > `stock.reclassified` only receive `locationId` / `loaderId` — look up `materialId` /
944
+ > `batchId` from the parent order by `orderId` if you need them.
945
+
946
+ #### Optional: `@OnEvent` via `@nestjs/event-emitter`
947
+
948
+ 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>`:
949
+
950
+ ```ts
951
+ import { EventEmitterModule } from '@nestjs/event-emitter';
952
+ import { wmsEventEmitterBridgeProvider } from '@rytass/wms-module-graphql/adapters/event-emitter';
953
+
954
+ @Module({
955
+ imports: [
956
+ EventEmitterModule.forRoot(),
957
+ WmsModuleGraphqlModule.forRootAsync({
958
+ ...coreOptions,
959
+ actorExtractor: memberBaseActorExtractor, // v2 — optional
960
+ enrichStockEvents: true, // v2 — optional
961
+ eventEmitterBridge: wmsEventEmitterBridgeProvider,
962
+ }),
963
+ ],
964
+ })
965
+ export class AppModule {}
966
+
967
+ // elsewhere
968
+ @Injectable()
969
+ class StockSync {
970
+ @OnEvent('wms.stock.allocated')
971
+ handle(payload: WmsStockEventPayload) { /* ... */ }
972
+ }
973
+ ```
974
+
975
+ 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.
976
+
977
+ > ⚠️ 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.
978
+
979
+ ---
980
+
872
981
  ## 9. Database support matrix
873
982
 
874
983
  | Database | Status | Notes |