@rytass/wms-module-graphql 0.5.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.
package/README.md CHANGED
@@ -96,19 +96,45 @@ Intervene in and get notified about mutations. Every root mutation carries a typ
96
96
  - **`afterListeners`** run **after the outermost transaction commits** — never notified about rolled-back changes.
97
97
 
98
98
  ```ts
99
+ import { memberBaseActorExtractor } from '@rytass/wms-module-graphql/adapters/member-base';
100
+
99
101
  WmsModuleGraphqlModule.forRootAsync({
100
102
  ...coreOptions,
103
+
104
+ // (v2) populate payload.actor on every event — reads req.payload → { id, domain }
105
+ actorExtractor: memberBaseActorExtractor,
106
+
107
+ // (v2) append materialId/batchId/locationId/loaderId to stock event payloads
108
+ enrichStockEvents: true,
109
+
101
110
  beforeHooks: {
102
- 'ship-inventory-order.shipped': async ({ args }) => {
103
- if (!allowed(args)) throw new ForbiddenException('blocked');
111
+ 'ship-inventory-order.shipped': async ({ args, actor }) => {
112
+ if (!allowed(args, actor)) throw new ForbiddenException('blocked');
104
113
  },
105
114
  },
106
115
  afterListeners: {
116
+ // v2: payload.args carries items[]; payload.actor carries { id, domain }
117
+ 'ship-inventory-order.shipped': async ({ payload }) => {
118
+ await erp.markShipped(payload.id, payload.result, payload.actor);
119
+ },
120
+ // v2 with enrichStockEvents: materialId/batchId/locationId/loaderId also available
107
121
  'stock.allocated': async ({ payload }) => { await erp.reserve(payload); },
108
122
  },
109
123
  });
110
124
  ```
111
125
 
126
+ **v2 payload enrichment** (upcoming minor — `core@0.3.0` / `graphql@0.6.0`):
127
+
128
+ - Operation events gain `args` (the GraphQL input that triggered the mutation) and `actor`
129
+ (`WmsActor | null`). Nine action mutations return `boolean`; their real information is in
130
+ `args` and the stock events they fan out.
131
+ - Stock events gain opt-in `materialId / batchId / locationId / loaderId` when
132
+ `enrichStockEvents: true` is set. **Caveat**: split / merge / reclassify order-items have
133
+ no `materialId` / `batchId` at the item level — only `locationId` / `loaderId` are
134
+ available for those three domains even with eager enrichment.
135
+ - `WmsOperationResultMap` provides a per-event typed overlay so subscribers receive the
136
+ concrete DTO (`MaterialDto`, `ShipInventoryOrderDto`, …) instead of `unknown`.
137
+
112
138
  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
139
 
114
140
  ➡️ **Full design, the complete 56-event list & limitations: [`docs/events.md`](./docs/events.md)**.
@@ -154,11 +180,11 @@ Full documentation is shipped inside this package under the `docs/` directory (a
154
180
 
155
181
  GitHub hosted links (always up-to-date):
156
182
 
157
- - [Integration Guide — §4 Backend setup](https://github.com/Rytass/WMS-Module/blob/main/docs/integration-guide.md#4-backend-setup-nestjs--graphql--typeorm)
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)
183
+ - [Integration Guide — §4 Backend setup](https://github.com/Rytass/WMS-Module/blob/main/libs/graphql/docs/integration-guide.md#4-backend-setup-nestjs--graphql--typeorm)
184
+ - [Integration Guide — §13.1 Existing GraphQLModule co-existence](https://github.com/Rytass/WMS-Module/blob/main/libs/graphql/docs/integration-guide.md#13-integrating-into-an-existing-nestjs--nextjs-project)
159
185
  - [Authorization — design & recipes](https://github.com/Rytass/WMS-Module/blob/main/libs/graphql/docs/authorization.md)
160
186
  - [Events & Hooks — design & event list](https://github.com/Rytass/WMS-Module/blob/main/libs/graphql/docs/events.md)
161
- - [API Reference — @rytass/wms-module-graphql](https://github.com/Rytass/WMS-Module/blob/main/docs/api-reference.md#rytasswms-module-graphql)
187
+ - [API Reference — @rytass/wms-module-graphql](https://github.com/Rytass/WMS-Module/blob/main/libs/graphql/docs/api-reference.md#rytasswms-module-graphql)
162
188
 
163
189
  ## License
164
190
 
@@ -1,3 +1,4 @@
1
+ import type { WmsActorExtractor } from '@rytass/wms-module-core';
1
2
  import type { WmsAuthChecker, WmsOperationRequirement } from '../lib/auth';
2
3
  /**
3
4
  * A Casbin `[subject, action]` tuple, e.g. `['wms:material', 'read']`.
@@ -33,3 +34,13 @@ export interface MemberBaseWmsAuthCheckerOptions {
33
34
  * (Apollo's default).
34
35
  */
35
36
  export declare const createMemberBaseWmsAuthChecker: (options: MemberBaseWmsAuthCheckerOptions) => WmsAuthChecker;
37
+ /**
38
+ * A {@link WmsActorExtractor} for `@rytass/member-base-nestjs-module`. Reads the
39
+ * verified JWT payload that `CasbinGuard` attaches to the request and surfaces it
40
+ * as the event/hook `actor`. Returns `null` for unauthenticated requests.
41
+ *
42
+ * Pass to `WmsModuleGraphqlModule.forRootAsync({ actorExtractor })` so every
43
+ * operation event and before-hook carries `{ id, domain }`. Imports nothing from
44
+ * member-base — it only reads the runtime request fields.
45
+ */
46
+ export declare const memberBaseActorExtractor: WmsActorExtractor;
@@ -43,5 +43,21 @@ const createMemberBaseWmsAuthChecker = (options) => {
43
43
  });
44
44
  };
45
45
  };
46
+ /**
47
+ * A {@link WmsActorExtractor} for `@rytass/member-base-nestjs-module`. Reads the
48
+ * verified JWT payload that `CasbinGuard` attaches to the request and surfaces it
49
+ * as the event/hook `actor`. Returns `null` for unauthenticated requests.
50
+ *
51
+ * Pass to `WmsModuleGraphqlModule.forRootAsync({ actorExtractor })` so every
52
+ * operation event and before-hook carries `{ id, domain }`. Imports nothing from
53
+ * member-base — it only reads the runtime request fields.
54
+ */
55
+ const memberBaseActorExtractor = (context) => {
56
+ const payload = extractRequest(context)?.payload;
57
+ if (!payload?.id) {
58
+ return null;
59
+ }
60
+ return { id: payload.id, domain: payload.domain, raw: payload };
61
+ };
46
62
 
47
- export { createMemberBaseWmsAuthChecker };
63
+ export { createMemberBaseWmsAuthChecker, memberBaseActorExtractor };
@@ -455,37 +455,57 @@ Opt-in `beforeHooks` (veto) and post-commit `afterListeners` for every mutation.
455
455
 
456
456
  ### Root exports (`@rytass/wms-module-graphql`, re-exported from `@rytass/wms-module-core`)
457
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? }` |
458
+ | Export | Kind | Signature / shape |
459
+ |-----------------------------------------------------------------|-----------|----------------------------------------------------------------------------------------------------------------------------------|
460
+ | `WmsMutationEvent` | decorator | `({ event: WmsOperationEventName, resolveId? }) => MethodDecorator` |
461
+ | `WmsEventModuleOptions` | type | `{ beforeHooks?, afterListeners?, actorExtractor?, enrichStockEvents? }` — 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; actor: WmsActor \| null }` (v2 adds `actor`) |
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 | v1: `{ id: string \| null; result: unknown }` — v2: `{ id; result; args: Readonly<Record<string, unknown>>; actor: WmsActor \| null }` |
470
+ | `WmsStockEventPayload` | type | `{ orderId; itemId; quantityDelta; changeType: ChangeType \| null; inspectionResult? }` — v2 adds optional `materialId?`, `batchId?`, `locationId?`, `loaderId?` when `enrichStockEvents: true` |
471
+ | `WmsActor` | type | `{ readonly id: string; readonly domain?: string; readonly raw?: unknown }` — actor identity (v2) |
472
+ | `WmsActorExtractor` | type | `(context: ExecutionContext) => WmsActor \| null` — injectable extractor (v2); token `WMS_ACTOR_EXTRACTOR` |
473
+ | `WmsOperationResultMap` | type | Maps each `WmsOperationEventName` to its concrete `result` DTO (e.g. `'material.created' → MaterialDto`); powers typed listeners (v2) |
471
474
 
472
475
  ### Adapter subpath (`@rytass/wms-module-graphql/adapters/event-emitter`)
473
476
 
474
- | Export | Kind | Purpose |
475
- |---|---|---|
477
+ | Export | Kind | Purpose |
478
+ |-------------------------------|------------|------------------------------------------------------------------------------------------------------------------------------------------|
476
479
  | `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
480
 
481
+ ### Adapter subpath (`@rytass/wms-module-graphql/adapters/member-base`) — event extras (v2)
482
+
483
+ | Export | Kind | Purpose |
484
+ |-----------------------------|-----------|-------------------------------------------------------------------------------------------------------------------------------------|
485
+ | `memberBaseActorExtractor` | extractor | `WmsActorExtractor` that reads `GqlExecutionContext.create(ctx).getContext().req?.payload` → `{ id, domain }`. Pass to `forRootAsync({ actorExtractor })`. |
486
+
478
487
  ```ts
488
+ import { memberBaseActorExtractor } from '@rytass/wms-module-graphql/adapters/member-base';
489
+ import { wmsEventEmitterBridgeProvider } from '@rytass/wms-module-graphql/adapters/event-emitter';
490
+
479
491
  WmsModuleGraphqlModule.forRootAsync({
480
492
  ...coreOptions,
481
- beforeHooks: { 'ship-inventory-order.shipped': async ({ args }) => { if (!ok(args)) throw new ForbiddenException(); } },
482
- afterListeners: { 'stock.allocated': async ({ payload }) => { await sync(payload); } },
493
+ actorExtractor: memberBaseActorExtractor, // v2: populates payload.actor
494
+ enrichStockEvents: true, // v2: appends ids to stock payloads
495
+ beforeHooks: {
496
+ 'ship-inventory-order.shipped': async ({ args, actor }) => {
497
+ if (!ok(args, actor)) throw new ForbiddenException();
498
+ },
499
+ },
500
+ afterListeners: {
501
+ 'stock.allocated': async ({ payload }) => { await sync(payload); },
502
+ },
483
503
  // optional @OnEvent bridge:
484
504
  // eventEmitterBridge: wmsEventEmitterBridgeProvider,
485
505
  });
486
506
  ```
487
507
 
488
- `beforeHooks` / `afterListeners` are **not** part of the GraphQL schema — they are runtime hooks. With none configured the interceptor passes through with zero overhead.
508
+ `beforeHooks` / `afterListeners` are **not** part of the GraphQL schema — they are runtime hooks. With none configured the interceptor passes through with zero overhead. All v2 options (`actorExtractor`, `enrichStockEvents`) are fully optional; omitting them preserves the v1 behaviour exactly.
489
509
 
490
510
  ---
491
511
 
package/docs/events.md CHANGED
@@ -36,6 +36,23 @@ Two hook points per event:
36
36
  Everything is optional. With nothing configured the module behaves exactly as
37
37
  before — no events, zero overhead.
38
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
+
39
56
  ---
40
57
 
41
58
  ## 2. Architecture — post-commit guarantee
@@ -82,6 +99,7 @@ is inferred from the key.
82
99
 
83
100
  ```ts
84
101
  import { WmsModuleGraphqlModule } from '@rytass/wms-module-graphql';
102
+ import { memberBaseActorExtractor } from '@rytass/wms-module-graphql/adapters/member-base';
85
103
  import { ForbiddenException } from '@nestjs/common';
86
104
 
87
105
  WmsModuleGraphqlModule.forRootAsync({
@@ -90,6 +108,12 @@ WmsModuleGraphqlModule.forRootAsync({
90
108
  defaultLoaderTypeKey: 'DEFAULT',
91
109
  defaultLocationKey: 'DEFAULT',
92
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
+
93
117
  // intervene — runs before any DB work; throw to veto
94
118
  beforeHooks: {
95
119
  'ship-inventory-order.shipped': async ({ args, executionContext }) => {
@@ -101,10 +125,12 @@ WmsModuleGraphqlModule.forRootAsync({
101
125
 
102
126
  // notify — runs after commit
103
127
  afterListeners: {
128
+ // v2: payload.args carries the items[] input; payload.actor carries { id, domain }
104
129
  'ship-inventory-order.shipped': async ({ payload }) => {
105
- await erp.markShipped(payload.id, payload.result);
130
+ await erp.markShipped(payload.id, payload.result, payload.actor);
106
131
  },
107
132
  'stock.allocated': async ({ payload }) => {
133
+ // v2 with enrichStockEvents: materialId/batchId/locationId/loaderId also available
108
134
  await erp.reserve(payload.orderId, payload.itemId, payload.quantityDelta);
109
135
  },
110
136
  },
@@ -117,6 +143,39 @@ WmsModuleGraphqlModule.forRootAsync({
117
143
  - An `afterListener` receives `{ event, payload }` — the committed result (see
118
144
  [§6](#6-payloads)). It may be async; a thrown error is logged and isolated, it
119
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.
120
179
 
121
180
  ---
122
181
 
@@ -158,52 +217,62 @@ and the bridge can be used together — both fire.
158
217
 
159
218
  ### Operation events (38) — `<domain>.<past-tense-action>`
160
219
 
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 | — |
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` | — |
201
265
 
202
266
  > The "Fans out to stock event" column is **indicative**, not a hard binding. A
203
267
  > mutation never names a stock event directly — `StockChangeSubscriber` derives
204
268
  > it from the `*_order_change` table that was written and the sign of
205
269
  > `quantityChanged`. So a mutation emits a stock event only on the code paths
206
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.
207
276
 
208
277
  ### Stock domain events (18) — `stock.<change-type>`
209
278
 
@@ -227,27 +296,141 @@ with a negative `quantityDelta` (`cancelShip` -> `stock.shipped`,
227
296
 
228
297
  ## 6. Payloads
229
298
 
230
- **Operation event** — `WmsOperationEventPayload`:
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:
231
322
 
232
323
  ```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)
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
+ }
235
329
  ```
236
330
 
237
- **Stock event** — `WmsStockEventPayload`:
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):
238
338
 
239
339
  ```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
340
+ interface WmsBeforeHookContext<N extends WmsEventName> {
341
+ event: N;
342
+ args: Record<string, unknown>;
343
+ executionContext: ExecutionContext;
344
+ actor: WmsActor | null; // v2 addition
345
+ }
245
346
  ```
246
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
+
247
430
  `orderId` is the same on the operation event (`payload.id`) and the stock events
248
431
  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.
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.
251
434
 
252
435
  ---
253
436
 
@@ -279,25 +462,38 @@ after-listener — the data is committed by then.
279
462
  From the package root (`@rytass/wms-module-graphql`, re-exported from
280
463
  `@rytass/wms-module-core`):
281
464
 
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. |
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). |
292
479
 
293
480
  From the adapter subpath (`@rytass/wms-module-graphql/adapters/event-emitter`):
294
481
 
295
- | Export | Kind | Purpose |
296
- |---|---|---|
297
- | `wmsEventEmitterBridgeProvider` | provider | Pass to `forRootAsync({ eventEmitterBridge })` to fan events onto `@nestjs/event-emitter`. |
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 })`. |
298
491
 
299
- `forRootAsync` accepts `beforeHooks`, `afterListeners` and `eventEmitterBridge`
300
- on its single options object — all optional.
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.
301
497
 
302
498
  ---
303
499
 
@@ -884,14 +884,23 @@ Every WMS mutation can be **intervened in** and **observed** through two opt-in
884
884
  // apps/api/src/app/app.module.ts
885
885
  import { ForbiddenException } from '@nestjs/common';
886
886
  import { WmsModuleGraphqlModule } from '@rytass/wms-module-graphql';
887
+ import { memberBaseActorExtractor } from '@rytass/wms-module-graphql/adapters/member-base';
887
888
 
888
889
  WmsModuleGraphqlModule.forRootAsync({
889
890
  // ...the same core options as §4.2 (defaultLoaderSerialId, useFactory, etc.)...
890
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
+
891
900
  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))) {
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))) {
895
904
  throw new ForbiddenException('shipment blocked');
896
905
  }
897
906
  },
@@ -899,10 +908,12 @@ WmsModuleGraphqlModule.forRootAsync({
899
908
 
900
909
  afterListeners: {
901
910
  // operation event — fires once per mutation, with the result
911
+ // v2: payload also carries args (the items[] input) and actor
902
912
  'ship-inventory-order.shipped': async ({ payload }) => {
903
- await erp.markShipped(payload.id, payload.result);
913
+ await erp.markShipped(payload.id, payload.result, payload.actor);
904
914
  },
905
915
  // stock domain event — fires once per affected item (post-commit)
916
+ // v2 with enrichStockEvents: materialId/batchId/locationId/loaderId also present
906
917
  'stock.allocated': async ({ payload }) => {
907
918
  await erp.reserve(payload.orderId, payload.itemId, payload.quantityDelta);
908
919
  },
@@ -912,11 +923,26 @@ WmsModuleGraphqlModule.forRootAsync({
912
923
 
913
924
  Two tiers of events you can listen to:
914
925
 
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.
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?`.
917
928
 
918
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).
919
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
+
920
946
  #### Optional: `@OnEvent` via `@nestjs/event-emitter`
921
947
 
922
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>`:
@@ -930,7 +956,9 @@ import { wmsEventEmitterBridgeProvider } from '@rytass/wms-module-graphql/adapte
930
956
  EventEmitterModule.forRoot(),
931
957
  WmsModuleGraphqlModule.forRootAsync({
932
958
  ...coreOptions,
933
- eventEmitterBridge: wmsEventEmitterBridgeProvider, // <- enables the bridge
959
+ actorExtractor: memberBaseActorExtractor, // v2 — optional
960
+ enrichStockEvents: true, // v2 — optional
961
+ eventEmitterBridge: wmsEventEmitterBridgeProvider,
934
962
  }),
935
963
  ],
936
964
  })
package/index.cjs.js CHANGED
@@ -330,9 +330,23 @@ const defaultResolveId = (result, args) => {
330
330
  exports.WmsMutationInterceptor = class WmsMutationInterceptor {
331
331
  reflector;
332
332
  dispatcher;
333
- constructor(reflector, dispatcher) {
333
+ actorExtractor;
334
+ constructor(reflector, dispatcher, actorExtractor = null) {
334
335
  this.reflector = reflector;
335
336
  this.dispatcher = dispatcher;
337
+ this.actorExtractor = actorExtractor;
338
+ }
339
+ resolveActor(context) {
340
+ if (!this.actorExtractor) {
341
+ return null;
342
+ }
343
+ try {
344
+ return this.actorExtractor(context);
345
+ }
346
+ catch {
347
+ // Fail-open on actor resolution: never break a mutation over audit metadata.
348
+ return null;
349
+ }
336
350
  }
337
351
  async intercept(context, next) {
338
352
  const config = this.reflector.get(WMS_MUTATION_EVENT_METADATA, context.getHandler());
@@ -341,11 +355,13 @@ exports.WmsMutationInterceptor = class WmsMutationInterceptor {
341
355
  }
342
356
  const args = graphql.GqlExecutionContext.create(context).getArgs() ??
343
357
  {};
358
+ const actor = this.resolveActor(context);
344
359
  // Before-hook runs before any DB work; a throw vetoes with zero side effects.
345
360
  await this.dispatcher.runBeforeHook({
346
361
  event: config.event,
347
362
  args,
348
363
  executionContext: context,
364
+ actor,
349
365
  });
350
366
  const { result, events } = await wmsModuleCore.runInDispatchScope(async () => {
351
367
  const handlerResult = await rxjs.firstValueFrom(next.handle());
@@ -354,7 +370,7 @@ exports.WmsMutationInterceptor = class WmsMutationInterceptor {
354
370
  : defaultResolveId(handlerResult, args);
355
371
  const operationEvent = {
356
372
  name: config.event,
357
- payload: { id, result: handlerResult },
373
+ payload: { id, result: handlerResult, args, actor },
358
374
  };
359
375
  wmsModuleCore.collectToScope(operationEvent);
360
376
  return handlerResult;
@@ -367,8 +383,10 @@ exports.WmsMutationInterceptor = class WmsMutationInterceptor {
367
383
  };
368
384
  exports.WmsMutationInterceptor = tslib.__decorate([
369
385
  common.Injectable(),
386
+ tslib.__param(2, common.Optional()),
387
+ tslib.__param(2, common.Inject(wmsModuleCore.WMS_ACTOR_EXTRACTOR)),
370
388
  tslib.__metadata("design:paramtypes", [core.Reflector,
371
- wmsModuleCore.WmsEventDispatcher])
389
+ wmsModuleCore.WmsEventDispatcher, Object])
372
390
  ], exports.WmsMutationInterceptor);
373
391
 
374
392
  /**
@@ -6521,15 +6539,23 @@ const exportDependencies = [
6521
6539
  ];
6522
6540
  exports.WmsModuleGraphqlModule = WmsModuleGraphqlModule_1 = class WmsModuleGraphqlModule {
6523
6541
  static forRootAsync(options) {
6524
- const { authChecker, beforeHooks, afterListeners, eventEmitterBridge, ...coreOptions } = options;
6542
+ const { authChecker, beforeHooks, afterListeners, actorExtractor, eventEmitterBridge, ...coreOptions } = options;
6543
+ // coreOptions still carries `enrichStockEvents`, forwarded to the core module.
6525
6544
  const coreModule = wmsModuleCore.WmsModuleCoreModule.forRootAsync(coreOptions);
6526
6545
  const authOptions = { authChecker };
6527
- const eventOptions = { beforeHooks, afterListeners };
6546
+ // The typed hook maps are a compile-time refinement of the core maps; at
6547
+ // runtime the dispatched payload IS the concrete result type, so handing the
6548
+ // refined maps to the (looser) core option contract is sound.
6549
+ const eventOptions = {
6550
+ beforeHooks: beforeHooks,
6551
+ afterListeners: afterListeners,
6552
+ };
6528
6553
  const providers = [
6529
6554
  ...exportDependencies,
6530
6555
  exports.WmsAuthGuard,
6531
6556
  { provide: WMS_AUTH_OPTIONS, useValue: authOptions },
6532
6557
  { provide: wmsModuleCore.WMS_EVENT_OPTIONS, useValue: eventOptions },
6558
+ { provide: wmsModuleCore.WMS_ACTOR_EXTRACTOR, useValue: actorExtractor ?? null },
6533
6559
  wmsModuleCore.WmsEventDispatcher,
6534
6560
  exports.WmsMutationInterceptor,
6535
6561
  ...(eventEmitterBridge ? [eventEmitterBridge] : []),
package/index.d.ts CHANGED
@@ -5,5 +5,5 @@ export type { LoaderType } from './lib/helpers/dataloader.type';
5
5
  export { createWmsLoaders, type WmsLoaders, } from './lib/helpers/create-wms-loaders';
6
6
  export { HOST_SKIP_METADATA_KEY, WmsAuthGuard, WmsOperation, WMS_AUTH_OPTIONS, WMS_OPERATION_METADATA, type WmsAuthChecker, type WmsAuthModuleOptions, type WmsOperationRequirement, } from './lib/auth';
7
7
  export type { WmsModuleGraphqlModuleAsyncOptions } from './lib/graphql.module';
8
- export { WmsMutationEvent, WmsMutationInterceptor, WMS_MUTATION_EVENT_METADATA, type WmsMutationEventConfig, } from './lib/events';
9
- export type { WmsAfterEventContext, WmsAfterListener, WmsAfterListenerMap, WmsBeforeHook, WmsBeforeHookContext, WmsBeforeHookMap, WmsDomainEvent, WmsEventModuleOptions, WmsEventName, WmsEventPayloadMap, WmsOperationEventName, WmsOperationEventPayload, WmsStockEventName, WmsStockEventPayload, } from '@rytass/wms-module-core';
8
+ export { WmsMutationEvent, WmsMutationInterceptor, WMS_MUTATION_EVENT_METADATA, type WmsMutationEventConfig, type WmsOperationResultMap, type WmsEventPayloadFor, type WmsTypedAfterEventContext, type WmsTypedAfterListenerMap, type WmsTypedBeforeHookContext, type WmsTypedBeforeHookMap, } from './lib/events';
9
+ export type { WmsActor, WmsActorExtractor, WmsAfterEventContext, WmsAfterListener, WmsAfterListenerMap, WmsBeforeHook, WmsBeforeHookContext, WmsBeforeHookMap, WmsDomainEvent, WmsEventModuleOptions, WmsEventName, WmsEventPayloadMap, WmsOperationEventName, WmsOperationEventPayload, WmsStockEventName, WmsStockEventPayload, } from '@rytass/wms-module-core';
@@ -1,3 +1,5 @@
1
1
  export { WmsMutationEvent } from './wms-mutation-event.decorator';
2
2
  export { WMS_MUTATION_EVENT_METADATA, type WmsMutationEventConfig, } from './wms-mutation-event.constants';
3
3
  export { WmsMutationInterceptor } from './wms-mutation.interceptor';
4
+ export type { WmsOperationResultMap } from './operation-result-map';
5
+ export type { WmsEventPayloadFor, WmsTypedAfterEventContext, WmsTypedAfterListenerMap, WmsTypedBeforeHookContext, WmsTypedBeforeHookMap, } from './typed-maps';
@@ -0,0 +1,77 @@
1
+ /**
2
+ * @file operation-result-map.ts
3
+ * Typed overlay that maps every {@link WmsOperationEventName} to the concrete
4
+ * DTO (or `boolean`) returned by that mutation. Used by {@link WmsEventPayloadFor}
5
+ * and the consumer-facing typed hook/listener maps in `typed-maps.ts`.
6
+ *
7
+ * `WmsOperationResultMap` extends `Record<WmsOperationEventName, unknown>` so
8
+ * the compiler rejects any key that is not a valid operation-event name, and
9
+ * flags missing keys at the interface level — every new mutation must add an
10
+ * entry here.
11
+ */
12
+ import type { WmsOperationEventName } from '@rytass/wms-module-core';
13
+ import type { AllocateInventoryOrderDto, HoldInventoryOrderDto, LoaderDto, LoaderTypeDto, MaterialDto, QualityInspectionOrderDto, ReceiveInventoryOrderDto, ScrapeOrderDto, ShipInventoryOrderDto, TransferCustomerOrderDto, TransferOrderDto, TransferVendorOrderDto } from '../dto';
14
+ import type { LocationDto } from '../dto/location.dto';
15
+ import type { MapDataDto } from '../dto/warehouse-map.dto';
16
+ import type { MergeBatchOrderDto } from '../dto/merge-batch-order.dto';
17
+ import type { ReclassifyInventoryOrderDto } from '../dto/reclassify-inventory-order.dto';
18
+ import type { SplitBatchOrderDto } from '../dto/split-batch-order.dto';
19
+ /**
20
+ * Maps every {@link WmsOperationEventName} to the concrete type returned by
21
+ * the corresponding mutation resolver.
22
+ *
23
+ * - **DTO-returning mutations (29)**: the resolved DTO class.
24
+ * - **Action mutations (9)**: `boolean` (success flag; real intent is in
25
+ * `WmsOperationEventPayload.args` and the accompanying stock events).
26
+ *
27
+ * Maintenance: for every new `@Mutation`, add the matching entry here.
28
+ */
29
+ export interface WmsOperationResultMap extends Record<WmsOperationEventName, unknown> {
30
+ 'material.created': MaterialDto;
31
+ 'material.updated': MaterialDto;
32
+ 'location.created': LocationDto;
33
+ 'location.updated': LocationDto;
34
+ 'warehouse-map.updated': MapDataDto;
35
+ 'loader.created': LoaderDto[];
36
+ 'loader.batch-created': LoaderDto[];
37
+ 'loader.updated': LoaderDto;
38
+ 'loader.type-created': LoaderTypeDto;
39
+ 'loader.type-updated': LoaderTypeDto;
40
+ 'receive-inventory-order.created': ReceiveInventoryOrderDto;
41
+ 'receive-inventory-order.updated': ReceiveInventoryOrderDto;
42
+ /** Action mutation — result is a success flag; use args + stock.received. */
43
+ 'receive-inventory-order.received': boolean;
44
+ 'allocate-inventory-order.created': AllocateInventoryOrderDto;
45
+ 'allocate-inventory-order.updated': AllocateInventoryOrderDto;
46
+ /** Action mutation — result is a success flag; use args + stock.deallocated. */
47
+ 'allocate-inventory-order.deallocated': boolean;
48
+ 'hold-inventory-order.created': HoldInventoryOrderDto;
49
+ 'hold-inventory-order.updated': HoldInventoryOrderDto;
50
+ /** Action mutation — result is a success flag; use args + stock.released. */
51
+ 'hold-inventory-order.released': boolean;
52
+ 'reclassify-inventory-order.reclassified': ReclassifyInventoryOrderDto;
53
+ 'ship-inventory-order.created': ShipInventoryOrderDto;
54
+ 'ship-inventory-order.updated': ShipInventoryOrderDto;
55
+ /** Action mutation — result is a success flag; use args + stock.shipped. */
56
+ 'ship-inventory-order.shipped': boolean;
57
+ /** Action mutation — result is a success flag; use args + stock.shipped (negative). */
58
+ 'ship-inventory-order.ship-cancelled': boolean;
59
+ 'batch.merged': MergeBatchOrderDto;
60
+ 'batch.splitted': SplitBatchOrderDto;
61
+ 'scrape-order.scraped': ScrapeOrderDto;
62
+ 'quality-inspection-order.inspected': QualityInspectionOrderDto;
63
+ 'transfer-order.created': TransferOrderDto;
64
+ 'transfer-order.updated': TransferOrderDto;
65
+ /** Action mutation — result is a success flag; use args + stock.transferred. */
66
+ 'transfer-order.transferred': boolean;
67
+ /** Action mutation — result is a success flag; use args + stock.in-transfer reversal. */
68
+ 'transfer-order.transfer-cancelled': boolean;
69
+ 'transfer-customer-order.created': TransferCustomerOrderDto;
70
+ 'transfer-customer-order.updated': TransferCustomerOrderDto;
71
+ /** Action mutation — result is a success flag; use args + stock.customer-returned. */
72
+ 'transfer-customer-order.returned': boolean;
73
+ 'transfer-vendor-order.created': TransferVendorOrderDto;
74
+ 'transfer-vendor-order.updated': TransferVendorOrderDto;
75
+ /** Action mutation — result is a success flag; use args + stock.vendor-returned. */
76
+ 'transfer-vendor-order.returned': boolean;
77
+ }
@@ -0,0 +1,73 @@
1
+ /**
2
+ * @file typed-maps.ts
3
+ * Consumer-facing typed hook/listener maps for the WMS mutation event system.
4
+ *
5
+ * These types give consumers per-event type safety when wiring `beforeHooks`
6
+ * and `afterListeners` in `WmsModuleGraphqlModule.forRootAsync(...)`. They
7
+ * layer on top of the runtime-erased core contracts without introducing any
8
+ * runtime dependency — everything here is `import type` only.
9
+ *
10
+ * Key exports
11
+ * -----------
12
+ * - {@link WmsEventPayloadFor} — resolves the payload type for a given event name
13
+ * - {@link WmsTypedAfterEventContext} — ctx passed to after-listeners
14
+ * - {@link WmsTypedBeforeHookContext} — ctx passed to before-hooks (pre-DB)
15
+ * - {@link WmsTypedAfterListenerMap} — `afterListeners` option with typed callbacks
16
+ * - {@link WmsTypedBeforeHookMap} — `beforeHooks` option with typed callbacks
17
+ */
18
+ import type { WmsActor, WmsEventName, WmsOperationEventName, WmsOperationEventPayload, WmsStockEventPayload } from '@rytass/wms-module-core';
19
+ import type { ExecutionContext } from '@nestjs/common';
20
+ import type { WmsOperationResultMap } from './operation-result-map';
21
+ /**
22
+ * Resolves the full event payload type for a given {@link WmsEventName}.
23
+ *
24
+ * - Operation events (`WmsOperationEventName`): `WmsOperationEventPayload`
25
+ * parameterised with the concrete result type from {@link WmsOperationResultMap}.
26
+ * - Stock events (`WmsStockEventName`): `WmsStockEventPayload` (ids-only by
27
+ * default; enriched when `enrichStockEvents: true`).
28
+ */
29
+ export type WmsEventPayloadFor<N extends WmsEventName> = N extends WmsOperationEventName ? WmsOperationEventPayload<WmsOperationResultMap[N]> : WmsStockEventPayload;
30
+ /**
31
+ * Context passed to an after-listener. Runs post-commit with the full
32
+ * typed event payload.
33
+ */
34
+ export interface WmsTypedAfterEventContext<N extends WmsEventName> {
35
+ readonly event: N;
36
+ readonly payload: WmsEventPayloadFor<N>;
37
+ }
38
+ /**
39
+ * Context passed to a before-hook. Runs *before* any DB work, so
40
+ * `payload` / `result` are not yet available — only the raw GraphQL
41
+ * arguments (`args`) and the resolved actor. Throwing from a before-hook
42
+ * vetoes the mutation with zero side effects.
43
+ */
44
+ export interface WmsTypedBeforeHookContext<N extends WmsEventName> {
45
+ readonly event: N;
46
+ /** Raw GraphQL arguments — the mutation intent (per §3.4 of the design doc). */
47
+ readonly args: Readonly<Record<string, unknown>>;
48
+ /** The NestJS execution context at the time of the mutation call. */
49
+ readonly executionContext: ExecutionContext;
50
+ /** Who performed the mutation; `null` when no `actorExtractor` is configured. */
51
+ readonly actor: WmsActor | null;
52
+ }
53
+ /**
54
+ * Typed `afterListeners` map. Each key is an optional per-event callback
55
+ * that receives a fully typed {@link WmsTypedAfterEventContext}.
56
+ *
57
+ * Pass an instance of this type as `afterListeners` in
58
+ * `WmsModuleGraphqlModule.forRootAsync(...)` to get per-event type safety.
59
+ */
60
+ export type WmsTypedAfterListenerMap = {
61
+ readonly [N in WmsEventName]?: (ctx: WmsTypedAfterEventContext<N>) => void | Promise<void>;
62
+ };
63
+ /**
64
+ * Typed `beforeHooks` map. Each key is an optional per-event callback
65
+ * that receives a fully typed {@link WmsTypedBeforeHookContext}.
66
+ *
67
+ * Throwing inside a before-hook vetoes the mutation before any transaction
68
+ * is opened. Pass an instance of this type as `beforeHooks` in
69
+ * `WmsModuleGraphqlModule.forRootAsync(...)`.
70
+ */
71
+ export type WmsTypedBeforeHookMap = {
72
+ readonly [N in WmsEventName]?: (ctx: WmsTypedBeforeHookContext<N>) => void | Promise<void>;
73
+ };
@@ -1,6 +1,6 @@
1
1
  import { type CallHandler, type ExecutionContext, type NestInterceptor } from '@nestjs/common';
2
2
  import { Reflector } from '@nestjs/core';
3
- import { WmsEventDispatcher } from '@rytass/wms-module-core';
3
+ import { WmsEventDispatcher, type WmsActorExtractor } from '@rytass/wms-module-core';
4
4
  import { type Observable } from 'rxjs';
5
5
  /**
6
6
  * Wraps each `@WmsMutationEvent` mutation in a dispatch scope:
@@ -14,6 +14,8 @@ import { type Observable } from 'rxjs';
14
14
  export declare class WmsMutationInterceptor implements NestInterceptor {
15
15
  private readonly reflector;
16
16
  private readonly dispatcher;
17
- constructor(reflector: Reflector, dispatcher: WmsEventDispatcher);
17
+ private readonly actorExtractor;
18
+ constructor(reflector: Reflector, dispatcher: WmsEventDispatcher, actorExtractor?: WmsActorExtractor | null);
19
+ private resolveActor;
18
20
  intercept(context: ExecutionContext, next: CallHandler): Promise<Observable<unknown>>;
19
21
  }
@@ -1,8 +1,8 @@
1
- import { __decorate, __metadata } from 'tslib';
2
- import { Injectable } from '@nestjs/common';
1
+ import { __decorate, __param, __metadata } from 'tslib';
2
+ import { Injectable, Optional, Inject } from '@nestjs/common';
3
3
  import { Reflector } from '@nestjs/core';
4
4
  import { GqlExecutionContext } from '@nestjs/graphql';
5
- import { WmsEventDispatcher, runInDispatchScope, collectToScope } from '@rytass/wms-module-core';
5
+ import { WMS_ACTOR_EXTRACTOR, WmsEventDispatcher, runInDispatchScope, collectToScope } from '@rytass/wms-module-core';
6
6
  import { firstValueFrom, of } from 'rxjs';
7
7
  import { WMS_MUTATION_EVENT_METADATA } from './wms-mutation-event.constants.js';
8
8
 
@@ -28,9 +28,23 @@ const defaultResolveId = (result, args) => {
28
28
  let WmsMutationInterceptor = class WmsMutationInterceptor {
29
29
  reflector;
30
30
  dispatcher;
31
- constructor(reflector, dispatcher) {
31
+ actorExtractor;
32
+ constructor(reflector, dispatcher, actorExtractor = null) {
32
33
  this.reflector = reflector;
33
34
  this.dispatcher = dispatcher;
35
+ this.actorExtractor = actorExtractor;
36
+ }
37
+ resolveActor(context) {
38
+ if (!this.actorExtractor) {
39
+ return null;
40
+ }
41
+ try {
42
+ return this.actorExtractor(context);
43
+ }
44
+ catch {
45
+ // Fail-open on actor resolution: never break a mutation over audit metadata.
46
+ return null;
47
+ }
34
48
  }
35
49
  async intercept(context, next) {
36
50
  const config = this.reflector.get(WMS_MUTATION_EVENT_METADATA, context.getHandler());
@@ -39,11 +53,13 @@ let WmsMutationInterceptor = class WmsMutationInterceptor {
39
53
  }
40
54
  const args = GqlExecutionContext.create(context).getArgs() ??
41
55
  {};
56
+ const actor = this.resolveActor(context);
42
57
  // Before-hook runs before any DB work; a throw vetoes with zero side effects.
43
58
  await this.dispatcher.runBeforeHook({
44
59
  event: config.event,
45
60
  args,
46
61
  executionContext: context,
62
+ actor,
47
63
  });
48
64
  const { result, events } = await runInDispatchScope(async () => {
49
65
  const handlerResult = await firstValueFrom(next.handle());
@@ -52,7 +68,7 @@ let WmsMutationInterceptor = class WmsMutationInterceptor {
52
68
  : defaultResolveId(handlerResult, args);
53
69
  const operationEvent = {
54
70
  name: config.event,
55
- payload: { id, result: handlerResult },
71
+ payload: { id, result: handlerResult, args, actor },
56
72
  };
57
73
  collectToScope(operationEvent);
58
74
  return handlerResult;
@@ -65,8 +81,10 @@ let WmsMutationInterceptor = class WmsMutationInterceptor {
65
81
  };
66
82
  WmsMutationInterceptor = __decorate([
67
83
  Injectable(),
84
+ __param(2, Optional()),
85
+ __param(2, Inject(WMS_ACTOR_EXTRACTOR)),
68
86
  __metadata("design:paramtypes", [Reflector,
69
- WmsEventDispatcher])
87
+ WmsEventDispatcher, Object])
70
88
  ], WmsMutationInterceptor);
71
89
 
72
90
  export { WmsMutationInterceptor };
@@ -1,12 +1,18 @@
1
1
  import { DynamicModule, type Provider } from '@nestjs/common';
2
- import { type WmsModuleCoreModuleAsyncOptions, type WmsEventModuleOptions } from '@rytass/wms-module-core';
2
+ import { type WmsActorExtractor, type WmsModuleCoreModuleAsyncOptions } from '@rytass/wms-module-core';
3
3
  import { type WmsAuthModuleOptions } from './auth';
4
+ import { type WmsTypedAfterListenerMap, type WmsTypedBeforeHookMap } from './events';
4
5
  /**
5
6
  * Options for `WmsModuleGraphqlModule.forRootAsync`. Extends the core module
6
7
  * options with:
7
8
  * - {@link WmsAuthModuleOptions.authChecker} — opt-in resolver authorization;
8
- * - {@link WmsEventModuleOptions} — `beforeHooks` (veto) / `afterListeners`
9
- * (post-commit notify) for the mutation event/hook system;
9
+ * - `beforeHooks` (veto) / `afterListeners` (post-commit notify) — the mutation
10
+ * event/hook system, **typed per event** (operation events carry their concrete
11
+ * DTO via {@link WmsTypedAfterListenerMap});
12
+ * - `actorExtractor` — populates `payload.actor` on every event (pass
13
+ * `memberBaseActorExtractor` from the member-base adapter, or your own);
14
+ * - `enrichStockEvents` — when `true`, stock events also carry
15
+ * `materialId/batchId/locationId/loaderId` (inherited from the core options);
10
16
  * - `eventEmitterBridge` — pass `wmsEventEmitterBridgeProvider` from
11
17
  * `@rytass/wms-module-graphql/adapters/event-emitter` to also fan events out
12
18
  * to `@nestjs/event-emitter` (`@OnEvent('wms.<event>')`).
@@ -14,7 +20,10 @@ import { type WmsAuthModuleOptions } from './auth';
14
20
  * Everything is optional; with nothing configured the module behaves exactly as
15
21
  * before (no events emitted, fully permissive).
16
22
  */
17
- export type WmsModuleGraphqlModuleAsyncOptions = WmsModuleCoreModuleAsyncOptions & WmsAuthModuleOptions & WmsEventModuleOptions & {
23
+ export type WmsModuleGraphqlModuleAsyncOptions = WmsModuleCoreModuleAsyncOptions & WmsAuthModuleOptions & {
24
+ readonly beforeHooks?: WmsTypedBeforeHookMap;
25
+ readonly afterListeners?: WmsTypedAfterListenerMap;
26
+ readonly actorExtractor?: WmsActorExtractor;
18
27
  readonly eventEmitterBridge?: Provider;
19
28
  };
20
29
  export declare class WmsModuleGraphqlModule {
@@ -1,6 +1,6 @@
1
1
  import { __decorate } from 'tslib';
2
2
  import { Module } from '@nestjs/common';
3
- import { WmsModuleCoreModule, WMS_EVENT_OPTIONS, WmsEventDispatcher } from '@rytass/wms-module-core';
3
+ import { WmsModuleCoreModule, WMS_EVENT_OPTIONS, WMS_ACTOR_EXTRACTOR, WmsEventDispatcher } from '@rytass/wms-module-core';
4
4
  import './helpers/loader.service.js';
5
5
  import { HelperModule } from './helpers/helper.module.js';
6
6
  import './helpers/query-path.js';
@@ -111,15 +111,23 @@ const exportDependencies = [
111
111
  ];
112
112
  let WmsModuleGraphqlModule = WmsModuleGraphqlModule_1 = class WmsModuleGraphqlModule {
113
113
  static forRootAsync(options) {
114
- const { authChecker, beforeHooks, afterListeners, eventEmitterBridge, ...coreOptions } = options;
114
+ const { authChecker, beforeHooks, afterListeners, actorExtractor, eventEmitterBridge, ...coreOptions } = options;
115
+ // coreOptions still carries `enrichStockEvents`, forwarded to the core module.
115
116
  const coreModule = WmsModuleCoreModule.forRootAsync(coreOptions);
116
117
  const authOptions = { authChecker };
117
- const eventOptions = { beforeHooks, afterListeners };
118
+ // The typed hook maps are a compile-time refinement of the core maps; at
119
+ // runtime the dispatched payload IS the concrete result type, so handing the
120
+ // refined maps to the (looser) core option contract is sound.
121
+ const eventOptions = {
122
+ beforeHooks: beforeHooks,
123
+ afterListeners: afterListeners,
124
+ };
118
125
  const providers = [
119
126
  ...exportDependencies,
120
127
  WmsAuthGuard,
121
128
  { provide: WMS_AUTH_OPTIONS, useValue: authOptions },
122
129
  { provide: WMS_EVENT_OPTIONS, useValue: eventOptions },
130
+ { provide: WMS_ACTOR_EXTRACTOR, useValue: actorExtractor ?? null },
123
131
  WmsEventDispatcher,
124
132
  WmsMutationInterceptor,
125
133
  ...(eventEmitterBridge ? [eventEmitterBridge] : []),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rytass/wms-module-graphql",
3
- "version": "0.5.0",
3
+ "version": "0.6.0",
4
4
  "description": "Rytass WMS Module — GraphQL schema, resolvers and DTOs for warehouse management",
5
5
  "keywords": [
6
6
  "rytass",
@@ -57,7 +57,7 @@
57
57
  "@nestjs/apollo": ">=13.0.0 <13.2.0",
58
58
  "@nestjs/common": "^11.0.0",
59
59
  "@nestjs/graphql": "^13.0.0",
60
- "@rytass/wms-module-core": "^0.2.0",
60
+ "@rytass/wms-module-core": "^0.3.0",
61
61
  "graphql": "^16.0.0",
62
62
  "reflect-metadata": "^0.1.13 || ^0.2.0",
63
63
  "rxjs": "^7.8.0",