@rytass/wms-module-graphql 0.5.0 → 0.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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
@@ -55,6 +55,65 @@ exports.LoaderService = class LoaderService {
55
55
  const fields = ___default.default(Object.keys(__from)).sort();
56
56
  return `${__hash}_${fields.map((field) => data[field]).join('_')}`;
57
57
  }
58
+ /**
59
+ * resolve a `__from` property name to the physical column it is stored in.
60
+ *
61
+ * TypeORM's `FindOptionsWhere` only accepts real column properties. A
62
+ * `@RelationId()` property — e.g. `LocationEntityClass.parentId`, which mirrors
63
+ * the `parent` tree relation — is virtual and is rejected with
64
+ * `EntityPropertyNotFoundError`, even though it is backed by the relation's
65
+ * join column. Map those back to their join column so they stay batchable.
66
+ * @param EntityMetadata
67
+ * @param string
68
+ * @returns string | null
69
+ */
70
+ resolveColumnName(metadata, propertyName) {
71
+ const column = metadata.findColumnWithPropertyName(propertyName);
72
+ if (column)
73
+ return column.databaseName;
74
+ const relationId = metadata.relationIds.find((candidate) => candidate.propertyName === propertyName);
75
+ const joinColumns = relationId?.relation.joinColumns ?? [];
76
+ return joinColumns.length === 1 ? joinColumns[0].databaseName : null;
77
+ }
78
+ /**
79
+ * build the where clause for `__from` keys that TypeORM's find options cannot
80
+ * express (see {@link resolveColumnName}), by addressing the columns directly.
81
+ * @param SelectQueryBuilder
82
+ * @param EntityMetadata
83
+ * @param LoaderOptions[]
84
+ * @param (keyof T)[]
85
+ */
86
+ applyColumnWhere(qb, metadata, options, primaryKeys) {
87
+ const columns = primaryKeys.map((primaryKey) => {
88
+ const columnName = this.resolveColumnName(metadata, String(primaryKey));
89
+ if (!columnName)
90
+ throw new Error(`Loader cannot resolve property "${String(primaryKey)}" to a column of "${metadata.name}".`);
91
+ return {
92
+ primaryKey,
93
+ column: `entity.${this.dataSource.driver.escape(columnName)}`,
94
+ };
95
+ });
96
+ if (columns.length === 1) {
97
+ const [{ primaryKey, column }] = columns;
98
+ qb.andWhere(`${column} IN (:...wmsLoaderValues)`, {
99
+ wmsLoaderValues: options.map(({ __from }) => __from[primaryKey]),
100
+ });
101
+ return;
102
+ }
103
+ qb.andWhere(new typeorm.Brackets((where) => {
104
+ options.forEach(({ __from }, optionIndex) => {
105
+ const parameters = {};
106
+ const predicate = columns
107
+ .map(({ primaryKey, column }, columnIndex) => {
108
+ const parameter = `wmsLoaderValue_${optionIndex}_${columnIndex}`;
109
+ parameters[parameter] = __from[primaryKey];
110
+ return `${column} = :${parameter}`;
111
+ })
112
+ .join(' AND ');
113
+ where.orWhere(`(${predicate})`, parameters);
114
+ });
115
+ }));
116
+ }
58
117
  /**
59
118
  * query data by collecting primary keys from options with same `__entity`
60
119
  * @param LoaderOptions[]
@@ -65,7 +124,10 @@ exports.LoaderService = class LoaderService {
65
124
  const repo = this.dataSource.getRepository(__entity);
66
125
  let qb = repo.createQueryBuilder('entity');
67
126
  const primaryKeys = Object.keys(__from);
68
- if (primaryKeys.length > 1)
127
+ const hasVirtualKey = primaryKeys.some((primaryKey) => !repo.metadata.findColumnWithPropertyName(String(primaryKey)));
128
+ if (hasVirtualKey)
129
+ this.applyColumnWhere(qb, repo.metadata, options, primaryKeys);
130
+ else if (primaryKeys.length > 1)
69
131
  qb.setFindOptions({ where: options.map(({ __from }) => __from) });
70
132
  else {
71
133
  const [primaryKey] = primaryKeys;
@@ -330,9 +392,23 @@ const defaultResolveId = (result, args) => {
330
392
  exports.WmsMutationInterceptor = class WmsMutationInterceptor {
331
393
  reflector;
332
394
  dispatcher;
333
- constructor(reflector, dispatcher) {
395
+ actorExtractor;
396
+ constructor(reflector, dispatcher, actorExtractor = null) {
334
397
  this.reflector = reflector;
335
398
  this.dispatcher = dispatcher;
399
+ this.actorExtractor = actorExtractor;
400
+ }
401
+ resolveActor(context) {
402
+ if (!this.actorExtractor) {
403
+ return null;
404
+ }
405
+ try {
406
+ return this.actorExtractor(context);
407
+ }
408
+ catch {
409
+ // Fail-open on actor resolution: never break a mutation over audit metadata.
410
+ return null;
411
+ }
336
412
  }
337
413
  async intercept(context, next) {
338
414
  const config = this.reflector.get(WMS_MUTATION_EVENT_METADATA, context.getHandler());
@@ -341,11 +417,13 @@ exports.WmsMutationInterceptor = class WmsMutationInterceptor {
341
417
  }
342
418
  const args = graphql.GqlExecutionContext.create(context).getArgs() ??
343
419
  {};
420
+ const actor = this.resolveActor(context);
344
421
  // Before-hook runs before any DB work; a throw vetoes with zero side effects.
345
422
  await this.dispatcher.runBeforeHook({
346
423
  event: config.event,
347
424
  args,
348
425
  executionContext: context,
426
+ actor,
349
427
  });
350
428
  const { result, events } = await wmsModuleCore.runInDispatchScope(async () => {
351
429
  const handlerResult = await rxjs.firstValueFrom(next.handle());
@@ -354,7 +432,7 @@ exports.WmsMutationInterceptor = class WmsMutationInterceptor {
354
432
  : defaultResolveId(handlerResult, args);
355
433
  const operationEvent = {
356
434
  name: config.event,
357
- payload: { id, result: handlerResult },
435
+ payload: { id, result: handlerResult, args, actor },
358
436
  };
359
437
  wmsModuleCore.collectToScope(operationEvent);
360
438
  return handlerResult;
@@ -367,8 +445,10 @@ exports.WmsMutationInterceptor = class WmsMutationInterceptor {
367
445
  };
368
446
  exports.WmsMutationInterceptor = tslib.__decorate([
369
447
  common.Injectable(),
448
+ tslib.__param(2, common.Optional()),
449
+ tslib.__param(2, common.Inject(wmsModuleCore.WMS_ACTOR_EXTRACTOR)),
370
450
  tslib.__metadata("design:paramtypes", [core.Reflector,
371
- wmsModuleCore.WmsEventDispatcher])
451
+ wmsModuleCore.WmsEventDispatcher, Object])
372
452
  ], exports.WmsMutationInterceptor);
373
453
 
374
454
  /**
@@ -3593,8 +3673,21 @@ let LocationResolver = class LocationResolver {
3593
3673
  __from: { id: parentId },
3594
3674
  });
3595
3675
  }
3596
- children({ children }) {
3597
- return children;
3676
+ /**
3677
+ * `children` must never be read off the parent entity: only
3678
+ * `LocationQuery.location` and `LocationService.findMany` eager-load the tree
3679
+ * relation. Every other producer of a `LocationDto` (stock/loader/change-log/
3680
+ * transfer/ship/receive/warehouse-map resolvers, the `parent` field above and
3681
+ * the create/update mutations) hands back an entity whose `children` is
3682
+ * `undefined`, which breaks the non-nullable list. Always resolve through the
3683
+ * request-scoped loader instead.
3684
+ */
3685
+ children({ id }, { loaders }) {
3686
+ return loaders.batchMany.load({
3687
+ __entity: wmsModuleCore.LocationEntityClass,
3688
+ __from: { parentId: id },
3689
+ __conditions: (qb) => qb.orderBy('entity."key"', 'ASC'),
3690
+ });
3598
3691
  }
3599
3692
  };
3600
3693
  tslib.__decorate([
@@ -3616,9 +3709,10 @@ tslib.__decorate([
3616
3709
  tslib.__decorate([
3617
3710
  graphql.ResolveField(() => [LocationDto]),
3618
3711
  tslib.__param(0, graphql.Parent()),
3712
+ tslib.__param(1, graphql.Context()),
3619
3713
  tslib.__metadata("design:type", Function),
3620
- tslib.__metadata("design:paramtypes", [wmsModuleCore.LocationEntityClass]),
3621
- tslib.__metadata("design:returntype", void 0)
3714
+ tslib.__metadata("design:paramtypes", [wmsModuleCore.LocationEntityClass, Object]),
3715
+ tslib.__metadata("design:returntype", Promise)
3622
3716
  ], LocationResolver.prototype, "children", null);
3623
3717
  LocationResolver = tslib.__decorate([
3624
3718
  graphql.Resolver(() => LocationDto)
@@ -6521,15 +6615,23 @@ const exportDependencies = [
6521
6615
  ];
6522
6616
  exports.WmsModuleGraphqlModule = WmsModuleGraphqlModule_1 = class WmsModuleGraphqlModule {
6523
6617
  static forRootAsync(options) {
6524
- const { authChecker, beforeHooks, afterListeners, eventEmitterBridge, ...coreOptions } = options;
6618
+ const { authChecker, beforeHooks, afterListeners, actorExtractor, eventEmitterBridge, ...coreOptions } = options;
6619
+ // coreOptions still carries `enrichStockEvents`, forwarded to the core module.
6525
6620
  const coreModule = wmsModuleCore.WmsModuleCoreModule.forRootAsync(coreOptions);
6526
6621
  const authOptions = { authChecker };
6527
- const eventOptions = { beforeHooks, afterListeners };
6622
+ // The typed hook maps are a compile-time refinement of the core maps; at
6623
+ // runtime the dispatched payload IS the concrete result type, so handing the
6624
+ // refined maps to the (looser) core option contract is sound.
6625
+ const eventOptions = {
6626
+ beforeHooks: beforeHooks,
6627
+ afterListeners: afterListeners,
6628
+ };
6528
6629
  const providers = [
6529
6630
  ...exportDependencies,
6530
6631
  exports.WmsAuthGuard,
6531
6632
  { provide: WMS_AUTH_OPTIONS, useValue: authOptions },
6532
6633
  { provide: wmsModuleCore.WMS_EVENT_OPTIONS, useValue: eventOptions },
6634
+ { provide: wmsModuleCore.WMS_ACTOR_EXTRACTOR, useValue: actorExtractor ?? null },
6533
6635
  wmsModuleCore.WmsEventDispatcher,
6534
6636
  exports.WmsMutationInterceptor,
6535
6637
  ...(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] : []),
@@ -33,6 +33,28 @@ export declare class LoaderService {
33
33
  * @returns Map<string, data>
34
34
  */
35
35
  private useIndex;
36
+ /**
37
+ * resolve a `__from` property name to the physical column it is stored in.
38
+ *
39
+ * TypeORM's `FindOptionsWhere` only accepts real column properties. A
40
+ * `@RelationId()` property — e.g. `LocationEntityClass.parentId`, which mirrors
41
+ * the `parent` tree relation — is virtual and is rejected with
42
+ * `EntityPropertyNotFoundError`, even though it is backed by the relation's
43
+ * join column. Map those back to their join column so they stay batchable.
44
+ * @param EntityMetadata
45
+ * @param string
46
+ * @returns string | null
47
+ */
48
+ private resolveColumnName;
49
+ /**
50
+ * build the where clause for `__from` keys that TypeORM's find options cannot
51
+ * express (see {@link resolveColumnName}), by addressing the columns directly.
52
+ * @param SelectQueryBuilder
53
+ * @param EntityMetadata
54
+ * @param LoaderOptions[]
55
+ * @param (keyof T)[]
56
+ */
57
+ private applyColumnWhere;
36
58
  /**
37
59
  * query data by collecting primary keys from options with same `__entity`
38
60
  * @param LoaderOptions[]