@rytass/wms-module-graphql 0.3.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +55 -0
- package/adapters/event-emitter.d.ts +29 -0
- package/adapters/event-emitter.js +41 -0
- package/adapters/member-base.d.ts +35 -0
- package/adapters/member-base.js +47 -0
- package/docs/README.md +6 -0
- package/docs/api-reference.md +100 -1
- package/docs/authorization.md +349 -0
- package/docs/events.md +304 -0
- package/docs/integration-guide.md +121 -4
- package/index.cjs.js +299 -3
- package/index.d.ts +4 -0
- package/index.js +7 -0
- package/lib/auth/host-skip.constant.d.ts +27 -0
- package/lib/auth/host-skip.constant.js +29 -0
- package/lib/auth/index.d.ts +4 -0
- package/lib/auth/wms-auth.guard.d.ts +19 -0
- package/lib/auth/wms-auth.guard.js +41 -0
- package/lib/auth/wms-auth.types.d.ts +36 -0
- package/lib/auth/wms-auth.types.js +6 -0
- package/lib/auth/wms-operation.decorator.d.ts +23 -0
- package/lib/auth/wms-operation.decorator.js +29 -0
- package/lib/events/index.d.ts +3 -0
- package/lib/events/wms-mutation-event.constants.d.ts +12 -0
- package/lib/events/wms-mutation-event.constants.js +4 -0
- package/lib/events/wms-mutation-event.decorator.d.ts +15 -0
- package/lib/events/wms-mutation-event.decorator.js +20 -0
- package/lib/events/wms-mutation.interceptor.d.ts +19 -0
- package/lib/events/wms-mutation.interceptor.js +72 -0
- package/lib/graphql.module.d.ts +20 -3
- package/lib/graphql.module.js +19 -4
- package/lib/mutations/allocate-inventory-order.mutation.js +10 -0
- package/lib/mutations/hold-inventory-order.mutation.js +10 -0
- package/lib/mutations/loader.mutation.js +14 -0
- package/lib/mutations/location.mutation.js +8 -0
- package/lib/mutations/material.mutation.js +8 -0
- package/lib/mutations/merge-batch.mutation.js +6 -0
- package/lib/mutations/quality-inspection-order.mutation.js +6 -0
- package/lib/mutations/receive-inventory-order.mutation.js +10 -0
- package/lib/mutations/reclassify-inventory-order.mutation.js +6 -0
- package/lib/mutations/scrape-order.mutation.js +6 -0
- package/lib/mutations/ship-inventory-order.mutation.js +12 -0
- package/lib/mutations/split-batch.mutation.js +6 -0
- package/lib/mutations/transfer-customer-order.mutation.js +10 -0
- package/lib/mutations/transfer-order.mutation.js +12 -0
- package/lib/mutations/transfer-vendor-order.mutation.js +10 -0
- package/lib/mutations/warehouse-map.mutation.js +6 -0
- package/lib/queries/allocate-inventory-order.query.js +3 -0
- package/lib/queries/batch.query.js +3 -0
- package/lib/queries/hold-inventory-order.query.js +3 -0
- package/lib/queries/loader.query.js +8 -0
- package/lib/queries/location.query.js +4 -0
- package/lib/queries/material.query.js +4 -0
- package/lib/queries/recieve-inventory-order.query.js +4 -0
- package/lib/queries/sale-order.query.js +4 -0
- package/lib/queries/ship-inventory-order.query.js +4 -0
- package/lib/queries/stock.query.js +7 -0
- package/lib/queries/transfer-order.query.js +4 -0
- package/lib/queries/unit.query.js +3 -0
- package/lib/queries/warehouse-map.query.js +3 -0
- package/package.json +17 -3
|
@@ -0,0 +1,349 @@
|
|
|
1
|
+
# Authorization
|
|
2
|
+
|
|
3
|
+
How `@rytass/wms-module-graphql` lets **you** authorize its resolvers with **your**
|
|
4
|
+
permission system — without the library knowing anything about it, and without
|
|
5
|
+
your global auth guard rejecting every WMS operation.
|
|
6
|
+
|
|
7
|
+
- [1. The problem](#1-the-problem)
|
|
8
|
+
- [2. The design](#2-the-design)
|
|
9
|
+
- [3. Execution order — why it composes](#3-execution-order--why-it-composes)
|
|
10
|
+
- [4. Recipe A — member-base / Casbin](#4-recipe-a--member-base--casbin)
|
|
11
|
+
- [5. Recipe B — your own JWT / RBAC](#5-recipe-b--your-own-jwt--rbac)
|
|
12
|
+
- [6. Recipe C — no global guard / fully open](#6-recipe-c--no-global-guard--fully-open)
|
|
13
|
+
- [7. The single coupling point](#7-the-single-coupling-point)
|
|
14
|
+
- [8. Public API](#8-public-api)
|
|
15
|
+
- [9. FAQ & gotchas](#9-faq--gotchas)
|
|
16
|
+
|
|
17
|
+
---
|
|
18
|
+
|
|
19
|
+
## 1. The problem
|
|
20
|
+
|
|
21
|
+
The library exposes ~66 root `@Query` / `@Mutation` operations. Like any
|
|
22
|
+
publishable NestJS GraphQL module, it cannot know how *your* application
|
|
23
|
+
authorizes requests — RBAC? ABAC? Casbin? JWT scopes? a custom session table?
|
|
24
|
+
So historically its resolvers were **bare** (`@Query()` with no guard metadata).
|
|
25
|
+
|
|
26
|
+
That collides badly with a **globally registered, deny-by-default guard**. The
|
|
27
|
+
canonical example is the `CasbinGuard` that `@rytass/member-base-nestjs-module`
|
|
28
|
+
registers as `APP_GUARD`:
|
|
29
|
+
|
|
30
|
+
```
|
|
31
|
+
Host App
|
|
32
|
+
APP_GUARD = CasbinGuard <- one net over EVERY resolver in the schema
|
|
33
|
+
|
|
|
34
|
+
v
|
|
35
|
+
+------------------------------+
|
|
36
|
+
| compiled GraphQL schema |
|
|
37
|
+
| - your resolvers | carry @AllowActions -> authorized OK
|
|
38
|
+
| - WMS resolvers (bare) | no metadata -> guard returns false -> ALL denied
|
|
39
|
+
+------------------------------+
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
`CasbinGuard` is white-list: a handler with no `@AllowActions` / `@Authenticated`
|
|
43
|
+
/ `@IsPublic` metadata is **rejected**. WMS resolvers had none, so every WMS
|
|
44
|
+
query and mutation came back *forbidden*. You could not fix it from the consumer
|
|
45
|
+
side either — you do not own the WMS resolver classes, so you cannot decorate
|
|
46
|
+
them.
|
|
47
|
+
|
|
48
|
+
**Conclusion:** the library itself must participate in authorization, while
|
|
49
|
+
staying ignorant of which auth system you use.
|
|
50
|
+
|
|
51
|
+
---
|
|
52
|
+
|
|
53
|
+
## 2. The design
|
|
54
|
+
|
|
55
|
+
A host-neutral, opt-in authorization protocol with three moving parts, all
|
|
56
|
+
shipped by the library:
|
|
57
|
+
|
|
58
|
+
| Part | What it is | Who supplies it |
|
|
59
|
+
|------|------------|-----------------|
|
|
60
|
+
| `@WmsOperation({ resource, action })` | Method decorator on every WMS root op. Declares a **neutral** permission requirement (just two strings) and wires the guard. | Library (already applied) |
|
|
61
|
+
| `WmsAuthGuard` | Route-level guard (NOT global) that reads the requirement and delegates the decision. | Library |
|
|
62
|
+
| `authChecker` | Your function `(ctx, { resource, action }) => boolean`. Maps WMS's neutral requirement onto your real permission system. | **You**, via `forRootAsync` |
|
|
63
|
+
|
|
64
|
+
```
|
|
65
|
+
WMS resolver
|
|
66
|
+
|
|
|
67
|
+
+- @WmsOperation({ resource: 'material', action: 'read' })
|
|
68
|
+
| |
|
|
69
|
+
| +- stamps neutral metadata ............ read by WmsAuthGuard
|
|
70
|
+
| +- stamps a host-skip marker ........... tells your global guard to defer
|
|
71
|
+
| +- @UseGuards(WmsAuthGuard) ............ the guard that actually enforces
|
|
72
|
+
|
|
|
73
|
+
v
|
|
74
|
+
WmsAuthGuard.canActivate(ctx)
|
|
75
|
+
|
|
|
76
|
+
+- reads { resource, action } from the handler
|
|
77
|
+
+- calls YOUR authChecker(ctx, { resource, action })
|
|
78
|
+
|
|
|
79
|
+
v
|
|
80
|
+
true -> resolve / false -> ForbiddenError
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
Key properties:
|
|
84
|
+
|
|
85
|
+
- **The library never imports your auth system.** It only knows two strings.
|
|
86
|
+
- **`authChecker` is optional.** Omit it and the module stays fully permissive
|
|
87
|
+
(handy for local dev / demos) — identical to the pre-auth releases.
|
|
88
|
+
- **`resource` / `action` are internal metadata**, not part of the GraphQL
|
|
89
|
+
schema. Refining them later is *not* a breaking change for GraphQL consumers.
|
|
90
|
+
- The host-skip marker is what lets WMS coexist with your global guard — see §3.
|
|
91
|
+
|
|
92
|
+
---
|
|
93
|
+
|
|
94
|
+
## 3. Execution order — why it composes
|
|
95
|
+
|
|
96
|
+
NestJS runs guards in the order **global -> controller -> route**. So a global
|
|
97
|
+
`APP_GUARD` (your `CasbinGuard`) runs *before* WMS's route-level `WmsAuthGuard`.
|
|
98
|
+
That is exactly why `@WmsOperation` stamps a host-skip marker: it makes the
|
|
99
|
+
global guard *defer* on WMS operations so it never blanket-rejects them.
|
|
100
|
+
|
|
101
|
+
```
|
|
102
|
+
request
|
|
103
|
+
|
|
|
104
|
+
v [1] APP_GUARD = CasbinGuard (global, runs first)
|
|
105
|
+
| - verifies token, attaches payload/enforcer to the request
|
|
106
|
+
| - sees host-skip marker (IS_ROUTE_PUBLIC) -> returns true (defers)
|
|
107
|
+
|
|
|
108
|
+
v [2] @UseGuards(WmsAuthGuard) (route-level, runs next)
|
|
109
|
+
| - reads { resource, action }
|
|
110
|
+
| - calls your authChecker -> real allow/deny decision
|
|
111
|
+
|
|
|
112
|
+
v resolver executes
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
With member-base specifically there is a bonus: before `CasbinGuard` defers on
|
|
116
|
+
the skip marker, it has *already* attached the verified `payload`, the Casbin
|
|
117
|
+
`enforcer`, and your `casbinPermissionChecker` to the request. The member-base
|
|
118
|
+
adapter (§4) reuses those — so WMS enforces with the **exact same** policy
|
|
119
|
+
engine and rules as your own resolvers.
|
|
120
|
+
|
|
121
|
+
> Your global guard configuration does **not** change. You do not whitelist WMS
|
|
122
|
+
> routes, you do not enumerate operations, you do not weaken your default-deny
|
|
123
|
+
> posture. WMS makes itself transparent to the global guard and self-enforces.
|
|
124
|
+
|
|
125
|
+
---
|
|
126
|
+
|
|
127
|
+
## 4. Recipe A — member-base / Casbin
|
|
128
|
+
|
|
129
|
+
The common case: you use `@rytass/member-base-nestjs-module` with its global
|
|
130
|
+
`CasbinGuard`. Use the ready-made adapter from the dedicated subpath.
|
|
131
|
+
|
|
132
|
+
```ts
|
|
133
|
+
// app.module.ts
|
|
134
|
+
import { WmsModuleGraphqlModule } from '@rytass/wms-module-graphql';
|
|
135
|
+
import { createMemberBaseWmsAuthChecker } from '@rytass/wms-module-graphql/adapters/member-base';
|
|
136
|
+
|
|
137
|
+
@Module({
|
|
138
|
+
imports: [
|
|
139
|
+
// ...MemberBaseModule.forRoot({ ... }) with its global guard enabled (default)...
|
|
140
|
+
WmsModuleGraphqlModule.forRootAsync({
|
|
141
|
+
// existing core options:
|
|
142
|
+
useFactory: async () => ({}),
|
|
143
|
+
defaultLoaderSerialId: 'DEFAULT',
|
|
144
|
+
defaultLoaderTypeKey: 'DEFAULT',
|
|
145
|
+
defaultLocationKey: 'DEFAULT',
|
|
146
|
+
|
|
147
|
+
// authorization:
|
|
148
|
+
authChecker: createMemberBaseWmsAuthChecker({
|
|
149
|
+
// Map WMS resource/action onto YOUR Casbin [subject, action] tuples.
|
|
150
|
+
// This is the one place your permission naming convention lives.
|
|
151
|
+
// Return several tuples to mean "allow if ANY passes" (OR semantics).
|
|
152
|
+
map: ({ resource, action }) => [[`wms:${resource}`, action]],
|
|
153
|
+
// material + read -> enforce(memberId, domain, 'wms:material', 'read')
|
|
154
|
+
}),
|
|
155
|
+
}),
|
|
156
|
+
// ...your own GraphQLModule.forRootAsync (must expose `req` on context)...
|
|
157
|
+
],
|
|
158
|
+
})
|
|
159
|
+
export class AppModule {}
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
What the adapter does, step by step:
|
|
163
|
+
|
|
164
|
+
1. Pulls the request off the GraphQL `ExecutionContext`
|
|
165
|
+
(`GqlExecutionContext.create(ctx).getContext().req` — member-base's own
|
|
166
|
+
convention).
|
|
167
|
+
2. Reads `request.payload` (the JWT member, attached by `CasbinGuard`). Missing
|
|
168
|
+
payload -> **deny** (unauthenticated).
|
|
169
|
+
3. Reads `request.enforcer` and `request.casbinPermissionChecker` (also attached
|
|
170
|
+
by `CasbinGuard`). Missing -> **deny** (fail closed: the global guard was not
|
|
171
|
+
actually enabled).
|
|
172
|
+
4. Runs `your map({ resource, action })` to get the Casbin tuples.
|
|
173
|
+
5. Calls the **consumer's own** `casbinPermissionChecker({ enforcer, payload,
|
|
174
|
+
actions })` and returns its verdict.
|
|
175
|
+
|
|
176
|
+
The adapter imports **nothing** from member-base — it relies only on the runtime
|
|
177
|
+
contract `CasbinGuard` already established on the request. So member-base is not
|
|
178
|
+
even a dependency of `@rytass/wms-module-graphql`.
|
|
179
|
+
|
|
180
|
+
**Prerequisites**
|
|
181
|
+
|
|
182
|
+
- Keep member-base's global guard enabled (`enableGlobalGuard: true`, the
|
|
183
|
+
default).
|
|
184
|
+
- Your `GraphQLModule` context must expose the request as `req` (Apollo's
|
|
185
|
+
default): `context: ({ req }) => ({ ...createWmsLoaders(loaderService), req })`.
|
|
186
|
+
|
|
187
|
+
**Permission grants.** Make sure your Casbin policy grants the mapped subjects.
|
|
188
|
+
With the example `map` above, a warehouse operator role might be granted
|
|
189
|
+
`wms:material:read`, `wms:receive-inventory-order:create`, etc. The full list of
|
|
190
|
+
`resource` values mirrors the WMS domains (`material`, `loader`, `stock`,
|
|
191
|
+
`location`, `unit`, `batch`, `warehouse-map`, `transfer-order`,
|
|
192
|
+
`receive-inventory-order`, `ship-inventory-order`, `allocate-inventory-order`,
|
|
193
|
+
`hold-inventory-order`, `reclassify-inventory-order`, `transfer-customer-order`,
|
|
194
|
+
`transfer-vendor-order`, `sale-order`, `scrape-order`,
|
|
195
|
+
`quality-inspection-order`). `action` is `read` for queries, and the leading
|
|
196
|
+
verb (`create` / `update` / `delete` / `ship` / `hold` / `allocate` / `split` /
|
|
197
|
+
`merge` / `transfer` / ...) for mutations.
|
|
198
|
+
|
|
199
|
+
---
|
|
200
|
+
|
|
201
|
+
## 5. Recipe B — your own JWT / RBAC
|
|
202
|
+
|
|
203
|
+
`authChecker` is a plain function — you do not need Casbin or member-base. Read
|
|
204
|
+
whatever you need off the `ExecutionContext` and decide.
|
|
205
|
+
|
|
206
|
+
```ts
|
|
207
|
+
import { GqlExecutionContext } from '@nestjs/graphql';
|
|
208
|
+
import type { WmsAuthChecker } from '@rytass/wms-module-graphql';
|
|
209
|
+
|
|
210
|
+
const myWmsAuthChecker: WmsAuthChecker = (ctx, { resource, action }) => {
|
|
211
|
+
const { req } = GqlExecutionContext.create(ctx).getContext<{ req: { user?: { scopes: string[] } } }>();
|
|
212
|
+
const user = req.user;
|
|
213
|
+
|
|
214
|
+
if (!user) return false; // unauthenticated -> deny
|
|
215
|
+
|
|
216
|
+
// your own convention; e.g. scopes like "wms:material:read" or "wms:*"
|
|
217
|
+
return user.scopes.includes('wms:*')
|
|
218
|
+
|| user.scopes.includes(`wms:${resource}:${action}`)
|
|
219
|
+
|| user.scopes.includes(`wms:${resource}:*`);
|
|
220
|
+
};
|
|
221
|
+
|
|
222
|
+
WmsModuleGraphqlModule.forRootAsync({
|
|
223
|
+
useFactory: async () => ({}),
|
|
224
|
+
defaultLoaderSerialId: 'DEFAULT',
|
|
225
|
+
defaultLoaderTypeKey: 'DEFAULT',
|
|
226
|
+
defaultLocationKey: 'DEFAULT',
|
|
227
|
+
authChecker: myWmsAuthChecker,
|
|
228
|
+
});
|
|
229
|
+
```
|
|
230
|
+
|
|
231
|
+
`authChecker` may be sync or async (return `Promise<boolean>`). Throwing also
|
|
232
|
+
works — the exception propagates exactly like any guard rejection (use a
|
|
233
|
+
`ForbiddenException` if you want a clean 403-style GraphQL error).
|
|
234
|
+
|
|
235
|
+
If you *also* run a global deny-by-default guard that is **not** member-base, you
|
|
236
|
+
must make it skip WMS routes too — see §7 to change the host-skip key.
|
|
237
|
+
|
|
238
|
+
---
|
|
239
|
+
|
|
240
|
+
## 6. Recipe C — no global guard / fully open
|
|
241
|
+
|
|
242
|
+
- **No `authChecker`.** The module is permissive: `WmsAuthGuard` lets every
|
|
243
|
+
operation through. The host-skip marker is inert. Good for local dev, internal
|
|
244
|
+
tooling, or demos.
|
|
245
|
+
|
|
246
|
+
```ts
|
|
247
|
+
WmsModuleGraphqlModule.forRootAsync({
|
|
248
|
+
useFactory: async () => ({}),
|
|
249
|
+
defaultLoaderSerialId: 'DEFAULT',
|
|
250
|
+
defaultLoaderTypeKey: 'DEFAULT',
|
|
251
|
+
defaultLocationKey: 'DEFAULT',
|
|
252
|
+
// no authChecker -> open
|
|
253
|
+
});
|
|
254
|
+
```
|
|
255
|
+
|
|
256
|
+
- **You guard your own resolvers explicitly** (`@UseGuards(...)` per resolver
|
|
257
|
+
instead of a global `APP_GUARD`). Then there is no net over WMS at all; the
|
|
258
|
+
skip marker does nothing and WMS still self-enforces through your
|
|
259
|
+
`authChecker` if you supply one.
|
|
260
|
+
|
|
261
|
+
---
|
|
262
|
+
|
|
263
|
+
## 7. The single coupling point
|
|
264
|
+
|
|
265
|
+
`@WmsOperation` stamps a host-skip metadata key so your global guard defers. A
|
|
266
|
+
global guard reads its skip key *statically* (at decoration time), so the key
|
|
267
|
+
must be a fixed value baked into the decorator. The default matches
|
|
268
|
+
member-base's:
|
|
269
|
+
|
|
270
|
+
```ts
|
|
271
|
+
// libs/graphql/src/lib/auth/host-skip.constant.ts
|
|
272
|
+
export const HOST_SKIP_METADATA_KEY = 'IS_ROUTE_PUBLIC';
|
|
273
|
+
```
|
|
274
|
+
|
|
275
|
+
This is the **only** place WMS assumes anything about a specific host guard. If
|
|
276
|
+
your global guard uses a different "public / skip" metadata key, change this one
|
|
277
|
+
constant (when building from source) — nothing else moves. If you do not run a
|
|
278
|
+
global guard, it is irrelevant.
|
|
279
|
+
|
|
280
|
+
---
|
|
281
|
+
|
|
282
|
+
## 8. Public API
|
|
283
|
+
|
|
284
|
+
Exported from the package root (`@rytass/wms-module-graphql`):
|
|
285
|
+
|
|
286
|
+
| Export | Kind | Purpose |
|
|
287
|
+
|--------|------|---------|
|
|
288
|
+
| `WmsOperation(requirement)` | decorator | Annotate a WMS root op (already applied across the library). |
|
|
289
|
+
| `WmsAuthGuard` | class | Route-level guard that delegates to `authChecker`. |
|
|
290
|
+
| `WmsAuthChecker` | type | `(ctx: ExecutionContext, req: WmsOperationRequirement) => boolean \| Promise<boolean>` |
|
|
291
|
+
| `WmsOperationRequirement` | type | `{ readonly resource: string; readonly action: string }` |
|
|
292
|
+
| `WmsAuthModuleOptions` | type | `{ readonly authChecker?: WmsAuthChecker }` |
|
|
293
|
+
| `WmsModuleGraphqlModuleAsyncOptions` | type | Core async options **+** `WmsAuthModuleOptions`. |
|
|
294
|
+
| `WMS_AUTH_OPTIONS` | symbol | DI token holding the resolved auth options. |
|
|
295
|
+
| `WMS_OPERATION_METADATA` | string | Reflector key carrying the requirement. |
|
|
296
|
+
| `HOST_SKIP_METADATA_KEY` | string | The host-skip metadata key (default `'IS_ROUTE_PUBLIC'`). |
|
|
297
|
+
|
|
298
|
+
Exported from the adapter subpath (`@rytass/wms-module-graphql/adapters/member-base`):
|
|
299
|
+
|
|
300
|
+
| Export | Kind | Purpose |
|
|
301
|
+
|--------|------|---------|
|
|
302
|
+
| `createMemberBaseWmsAuthChecker(options)` | factory | Builds a `WmsAuthChecker` for member-base / Casbin. |
|
|
303
|
+
| `MemberBaseWmsAuthCheckerOptions` | type | `{ readonly map: (req: WmsOperationRequirement) => CasbinAction[] }` |
|
|
304
|
+
| `CasbinAction` | type | `[string, string]` — a `[subject, action]` tuple. |
|
|
305
|
+
|
|
306
|
+
`forRootAsync` accepts the optional `authChecker` on its single options object:
|
|
307
|
+
|
|
308
|
+
```ts
|
|
309
|
+
static forRootAsync(options: WmsModuleGraphqlModuleAsyncOptions): DynamicModule;
|
|
310
|
+
```
|
|
311
|
+
|
|
312
|
+
---
|
|
313
|
+
|
|
314
|
+
## 9. FAQ & gotchas
|
|
315
|
+
|
|
316
|
+
**"Every WMS query/mutation is forbidden after I added member-base."**
|
|
317
|
+
Your global `CasbinGuard` is rejecting bare resolvers. Upgrade to a WMS version
|
|
318
|
+
that ships `@WmsOperation` (this protocol) and wire an `authChecker` per §4. You
|
|
319
|
+
do not need to touch the global guard.
|
|
320
|
+
|
|
321
|
+
**Does the GraphQL context need anything special?**
|
|
322
|
+
Yes — expose the request as `req` on the context (Apollo's default). Both the
|
|
323
|
+
member-base adapter and most custom checkers read `GqlExecutionContext.create(ctx).getContext().req`.
|
|
324
|
+
|
|
325
|
+
**Is member-base a dependency now?**
|
|
326
|
+
No. The adapter reads runtime fields the host's guard attaches to the request;
|
|
327
|
+
it imports nothing from member-base. member-base is not in this package's
|
|
328
|
+
`dependencies` or `peerDependencies`.
|
|
329
|
+
|
|
330
|
+
**What about `@ResolveField` resolvers?**
|
|
331
|
+
They are intentionally *not* annotated. A field resolver is only ever reached
|
|
332
|
+
through a root `@Query`/`@Mutation` that already passed `WmsAuthGuard`, so
|
|
333
|
+
authorizing the root op authorizes the whole selection. `WmsAuthGuard` returns
|
|
334
|
+
`true` for any handler without a requirement.
|
|
335
|
+
|
|
336
|
+
**Fail-open or fail-closed?**
|
|
337
|
+
The library is permissive only when *you* opt out (no `authChecker`). When an
|
|
338
|
+
`authChecker` is configured, missing identity / missing enforcer -> **deny**. The
|
|
339
|
+
member-base adapter fails closed.
|
|
340
|
+
|
|
341
|
+
**Can I change `resource`/`action` strings?**
|
|
342
|
+
Yes, freely — they are internal metadata, never exposed in the GraphQL schema.
|
|
343
|
+
Changing them only affects your `authChecker` / `map`, never your GraphQL
|
|
344
|
+
clients.
|
|
345
|
+
|
|
346
|
+
---
|
|
347
|
+
|
|
348
|
+
See also: [Integration Guide §8.5](./integration-guide.md#85-backend-authorization-resolver-level)
|
|
349
|
+
· [API Reference](./api-reference.md#rytasswms-module-graphql)
|
package/docs/events.md
ADDED
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
# Events & Hooks
|
|
2
|
+
|
|
3
|
+
How a consumer **intervenes in** (vetoes) and **gets notified about** WMS
|
|
4
|
+
mutations, with a host-neutral, opt-in API that mirrors the
|
|
5
|
+
[authorization](./authorization.md) layer's wiring.
|
|
6
|
+
|
|
7
|
+
- [1. What you get](#1-what-you-get)
|
|
8
|
+
- [2. Architecture — post-commit guarantee](#2-architecture--post-commit-guarantee)
|
|
9
|
+
- [3. Integration](#3-integration)
|
|
10
|
+
- [4. The `@nestjs/event-emitter` bridge](#4-the-nestjsevent-emitter-bridge)
|
|
11
|
+
- [5. Complete event list](#5-complete-event-list)
|
|
12
|
+
- [6. Payloads](#6-payloads)
|
|
13
|
+
- [7. Guarantees & limitations](#7-guarantees--limitations)
|
|
14
|
+
- [8. Public API](#8-public-api)
|
|
15
|
+
|
|
16
|
+
---
|
|
17
|
+
|
|
18
|
+
## 1. What you get
|
|
19
|
+
|
|
20
|
+
Two tiers of events, both opt-in:
|
|
21
|
+
|
|
22
|
+
- **Operation events** — exactly one per root mutation (`material.created`,
|
|
23
|
+
`ship-inventory-order.shipped`, …). 38 in total.
|
|
24
|
+
- **Stock domain events** — driven by the `ChangeType` state machine
|
|
25
|
+
(`stock.allocated`, `stock.shipped`, …), emitted once per stock movement, so a
|
|
26
|
+
single operation can fan out several. 18 in total.
|
|
27
|
+
|
|
28
|
+
Two hook points per event:
|
|
29
|
+
|
|
30
|
+
- **`beforeHooks`** run *before* any DB work. Throwing **vetoes** the whole
|
|
31
|
+
mutation — nothing is written, no after-listener fires. Use for custom
|
|
32
|
+
authorization, validation, quota enforcement.
|
|
33
|
+
- **`afterListeners`** run *after the outermost transaction commits*. Use for
|
|
34
|
+
audit logging, ERP/webhook sync, cache invalidation, notifications.
|
|
35
|
+
|
|
36
|
+
Everything is optional. With nothing configured the module behaves exactly as
|
|
37
|
+
before — no events, zero overhead.
|
|
38
|
+
|
|
39
|
+
---
|
|
40
|
+
|
|
41
|
+
## 2. Architecture — post-commit guarantee
|
|
42
|
+
|
|
43
|
+
The hard requirement is that an after-listener must **never** be told about a
|
|
44
|
+
change that was rolled back. WMS guarantees this with an `AsyncLocalStorage`
|
|
45
|
+
dispatch scope opened by a per-mutation interceptor:
|
|
46
|
+
|
|
47
|
+
```
|
|
48
|
+
GraphQL Mutation (@WmsMutationEvent)
|
|
49
|
+
|
|
|
50
|
+
v
|
|
51
|
+
WmsMutationInterceptor
|
|
52
|
+
1. beforeHook(event, args) --throw--> VETO (no transaction, clean error)
|
|
53
|
+
2. als.run(scope, async () => {
|
|
54
|
+
result = await handler() // services open tx, write *_order_change rows
|
|
55
|
+
}) // -> StockChangeSubscriber.afterInsert
|
|
56
|
+
| // collects stock.* events into the scope
|
|
57
|
+
| handler resolved == transaction committed
|
|
58
|
+
v
|
|
59
|
+
3. flush(scope.buffer)
|
|
60
|
+
afterListener(event) (isolated; one failure never blocks others)
|
|
61
|
+
emitterBridge.forward(event) --> EventEmitter2 (optional)
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
Why it is post-commit: TypeORM's `dataSource.transaction(cb)` resolves only
|
|
65
|
+
*after* `COMMIT`. The interceptor awaits the whole handler before flushing, so
|
|
66
|
+
the buffer is dispatched strictly after commit. If the transaction rolls back
|
|
67
|
+
the handler rejects, the interceptor never flushes, and the buffered events are
|
|
68
|
+
discarded.
|
|
69
|
+
|
|
70
|
+
Stock events are collected centrally by a single TypeORM subscriber
|
|
71
|
+
(`StockChangeSubscriber`) that listens to every `*_order_change` insert and
|
|
72
|
+
derives the `ChangeType` from the table name and the sign of `quantityChanged`.
|
|
73
|
+
The 12 domain services are untouched, and any future code path that writes a
|
|
74
|
+
change row is covered automatically.
|
|
75
|
+
|
|
76
|
+
---
|
|
77
|
+
|
|
78
|
+
## 3. Integration
|
|
79
|
+
|
|
80
|
+
`beforeHooks` / `afterListeners` are typed maps keyed by event name; the payload
|
|
81
|
+
is inferred from the key.
|
|
82
|
+
|
|
83
|
+
```ts
|
|
84
|
+
import { WmsModuleGraphqlModule } from '@rytass/wms-module-graphql';
|
|
85
|
+
import { ForbiddenException } from '@nestjs/common';
|
|
86
|
+
|
|
87
|
+
WmsModuleGraphqlModule.forRootAsync({
|
|
88
|
+
useFactory: async () => ({}),
|
|
89
|
+
defaultLoaderSerialId: 'DEFAULT',
|
|
90
|
+
defaultLoaderTypeKey: 'DEFAULT',
|
|
91
|
+
defaultLocationKey: 'DEFAULT',
|
|
92
|
+
|
|
93
|
+
// intervene — runs before any DB work; throw to veto
|
|
94
|
+
beforeHooks: {
|
|
95
|
+
'ship-inventory-order.shipped': async ({ args, executionContext }) => {
|
|
96
|
+
if (!(await canShip(args, executionContext))) {
|
|
97
|
+
throw new ForbiddenException('shipment blocked');
|
|
98
|
+
}
|
|
99
|
+
},
|
|
100
|
+
},
|
|
101
|
+
|
|
102
|
+
// notify — runs after commit
|
|
103
|
+
afterListeners: {
|
|
104
|
+
'ship-inventory-order.shipped': async ({ payload }) => {
|
|
105
|
+
await erp.markShipped(payload.id, payload.result);
|
|
106
|
+
},
|
|
107
|
+
'stock.allocated': async ({ payload }) => {
|
|
108
|
+
await erp.reserve(payload.orderId, payload.itemId, payload.quantityDelta);
|
|
109
|
+
},
|
|
110
|
+
},
|
|
111
|
+
});
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
- A `beforeHook` receives `{ event, args, executionContext }` — the mutation
|
|
115
|
+
**intent** (raw GraphQL args), since no result exists yet. Throw (e.g. a
|
|
116
|
+
`ForbiddenException`) to abort with zero side effects.
|
|
117
|
+
- An `afterListener` receives `{ event, payload }` — the committed result (see
|
|
118
|
+
[§6](#6-payloads)). It may be async; a thrown error is logged and isolated, it
|
|
119
|
+
never affects the committed mutation or other listeners.
|
|
120
|
+
|
|
121
|
+
---
|
|
122
|
+
|
|
123
|
+
## 4. The `@nestjs/event-emitter` bridge
|
|
124
|
+
|
|
125
|
+
Prefer decoupled `@OnEvent` subscribers? Opt into the bridge. It is the only
|
|
126
|
+
thing that imports `@nestjs/event-emitter` (an **optional** peer dependency), so
|
|
127
|
+
the core module never depends on it.
|
|
128
|
+
|
|
129
|
+
```ts
|
|
130
|
+
import { EventEmitterModule } from '@nestjs/event-emitter';
|
|
131
|
+
import { wmsEventEmitterBridgeProvider } from '@rytass/wms-module-graphql/adapters/event-emitter';
|
|
132
|
+
|
|
133
|
+
@Module({
|
|
134
|
+
imports: [
|
|
135
|
+
EventEmitterModule.forRoot(),
|
|
136
|
+
WmsModuleGraphqlModule.forRootAsync({
|
|
137
|
+
...coreOptions,
|
|
138
|
+
eventEmitterBridge: wmsEventEmitterBridgeProvider,
|
|
139
|
+
}),
|
|
140
|
+
],
|
|
141
|
+
})
|
|
142
|
+
export class AppModule {}
|
|
143
|
+
|
|
144
|
+
// anywhere
|
|
145
|
+
@Injectable()
|
|
146
|
+
class StockSync {
|
|
147
|
+
@OnEvent('wms.stock.allocated')
|
|
148
|
+
handle(payload: WmsStockEventPayload) { /* ... */ }
|
|
149
|
+
}
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
Every event is emitted as `wms.<event-name>` with its payload. `afterListeners`
|
|
153
|
+
and the bridge can be used together — both fire.
|
|
154
|
+
|
|
155
|
+
---
|
|
156
|
+
|
|
157
|
+
## 5. Complete event list
|
|
158
|
+
|
|
159
|
+
### Operation events (38) — `<domain>.<past-tense-action>`
|
|
160
|
+
|
|
161
|
+
| Event | Mutation | Fans out to stock event |
|
|
162
|
+
|---|---|---|
|
|
163
|
+
| `receive-inventory-order.created` | createReceiveInventoryOrder | — |
|
|
164
|
+
| `receive-inventory-order.updated` | updateReceiveInventoryOrder | — |
|
|
165
|
+
| `receive-inventory-order.received` | receive | `stock.received` |
|
|
166
|
+
| `ship-inventory-order.created` | createShipInventoryOrder | — |
|
|
167
|
+
| `ship-inventory-order.updated` | updateShipInventoryOrder | `stock.in-shipment` |
|
|
168
|
+
| `ship-inventory-order.shipped` | ship | `stock.shipped` |
|
|
169
|
+
| `ship-inventory-order.ship-cancelled` | cancelShip | `stock.shipped` (negative) |
|
|
170
|
+
| `transfer-order.created` | createTransferOrder | `stock.in-transfer` |
|
|
171
|
+
| `transfer-order.updated` | updateTransferOrder | `stock.in-transfer` |
|
|
172
|
+
| `transfer-order.transferred` | transfer | `stock.transferred` |
|
|
173
|
+
| `transfer-order.transfer-cancelled` | cancelTransfer | `stock.in-transfer` (reversal) |
|
|
174
|
+
| `hold-inventory-order.created` | createHoldInventoryOrder | `stock.held` |
|
|
175
|
+
| `hold-inventory-order.updated` | updateHoldInventoryOrder | `stock.held` |
|
|
176
|
+
| `hold-inventory-order.released` | release | `stock.released` |
|
|
177
|
+
| `allocate-inventory-order.created` | createAllocateInventoryOrder | `stock.allocated` |
|
|
178
|
+
| `allocate-inventory-order.updated` | updateAllocateInventoryOrder | `stock.allocated` |
|
|
179
|
+
| `allocate-inventory-order.deallocated` | deallocate | `stock.deallocated` |
|
|
180
|
+
| `transfer-vendor-order.created` | createTransferVendorOrder | `stock.vendor-transferred` |
|
|
181
|
+
| `transfer-vendor-order.updated` | updateTransferVendorOrder | `stock.vendor-transferred` |
|
|
182
|
+
| `transfer-vendor-order.returned` | returnTransferVendorOrder | `stock.vendor-returned` |
|
|
183
|
+
| `transfer-customer-order.created` | createTransferCustomerOrder | `stock.customer-held` |
|
|
184
|
+
| `transfer-customer-order.updated` | updateTransferCustomerOrder | `stock.customer-held` |
|
|
185
|
+
| `transfer-customer-order.returned` | returnTransferCustomerOrder | `stock.customer-returned` |
|
|
186
|
+
| `quality-inspection-order.inspected` | inspectMaterials | `stock.inspection-resolved` |
|
|
187
|
+
| `scrape-order.scraped` | scrapeMaterials | `stock.scraped` |
|
|
188
|
+
| `reclassify-inventory-order.reclassified` | reclassifyInventoryOrder | `stock.reclassified` |
|
|
189
|
+
| `batch.merged` | mergeBatch | `stock.merged` |
|
|
190
|
+
| `batch.splitted` | splitBatch | `stock.splitted` |
|
|
191
|
+
| `material.created` | createMaterial | — |
|
|
192
|
+
| `material.updated` | updateMaterial | — |
|
|
193
|
+
| `loader.created` | createLoader | — |
|
|
194
|
+
| `loader.batch-created` | createBatchLoader | — |
|
|
195
|
+
| `loader.updated` | updateLoader | — |
|
|
196
|
+
| `loader.type-created` | createLoaderType | — |
|
|
197
|
+
| `loader.type-updated` | updateLoaderType | — |
|
|
198
|
+
| `location.created` | createLocation | — |
|
|
199
|
+
| `location.updated` | updateLocation | — |
|
|
200
|
+
| `warehouse-map.updated` | updateWarehouseMap | — |
|
|
201
|
+
|
|
202
|
+
> The "Fans out to stock event" column is **indicative**, not a hard binding. A
|
|
203
|
+
> mutation never names a stock event directly — `StockChangeSubscriber` derives
|
|
204
|
+
> it from the `*_order_change` table that was written and the sign of
|
|
205
|
+
> `quantityChanged`. So a mutation emits a stock event only on the code paths
|
|
206
|
+
> that actually write a change row (e.g. an `update*` only when it adds items).
|
|
207
|
+
|
|
208
|
+
### Stock domain events (18) — `stock.<change-type>`
|
|
209
|
+
|
|
210
|
+
`stock.received`, `stock.in-shipment`, `stock.shipped`, `stock.in-transfer`,
|
|
211
|
+
`stock.transferred`, `stock.vendor-transferred`, `stock.vendor-returned`,
|
|
212
|
+
`stock.held`, `stock.released`, `stock.customer-held`,
|
|
213
|
+
`stock.customer-returned`, `stock.splitted`, `stock.merged`, `stock.allocated`,
|
|
214
|
+
`stock.deallocated`, `stock.reclassified`, `stock.scraped`,
|
|
215
|
+
`stock.inspection-resolved`.
|
|
216
|
+
|
|
217
|
+
The first 17 map 1:1 to `ChangeType`. `stock.inspection-resolved` has no
|
|
218
|
+
`ChangeType` (quality inspection only flips the `inspectionResult` flag) and
|
|
219
|
+
carries `changeType: null` plus `inspectionResult`.
|
|
220
|
+
|
|
221
|
+
A single operation can emit **several** stock events — one per `*_order_change`
|
|
222
|
+
row written, i.e. one per affected item. Cancellations re-use the forward event
|
|
223
|
+
with a negative `quantityDelta` (`cancelShip` -> `stock.shipped`,
|
|
224
|
+
`cancelTransfer` -> `stock.in-transfer`); branch on the sign, not the name only.
|
|
225
|
+
|
|
226
|
+
---
|
|
227
|
+
|
|
228
|
+
## 6. Payloads
|
|
229
|
+
|
|
230
|
+
**Operation event** — `WmsOperationEventPayload`:
|
|
231
|
+
|
|
232
|
+
```ts
|
|
233
|
+
{ id: string | null; // primary id of the operation's order/entity
|
|
234
|
+
result: unknown } // the mutation's return value (DTO, or boolean for actions)
|
|
235
|
+
```
|
|
236
|
+
|
|
237
|
+
**Stock event** — `WmsStockEventPayload`:
|
|
238
|
+
|
|
239
|
+
```ts
|
|
240
|
+
{ orderId: string; // = change.id; correlation id with the operation event
|
|
241
|
+
itemId: string; // = change.itemId
|
|
242
|
+
quantityDelta: number; // = change.quantityChanged (sign = direction)
|
|
243
|
+
changeType: ChangeType | null; // null only for stock.inspection-resolved
|
|
244
|
+
inspectionResult?: boolean | null } // present only on stock.inspection-resolved
|
|
245
|
+
```
|
|
246
|
+
|
|
247
|
+
`orderId` is the same on the operation event (`payload.id`) and the stock events
|
|
248
|
+
it fans out to, so listeners can correlate the two tiers. For richer
|
|
249
|
+
batch/material/location details, look them up by `orderId` / `itemId` inside the
|
|
250
|
+
after-listener — the data is committed by then.
|
|
251
|
+
|
|
252
|
+
---
|
|
253
|
+
|
|
254
|
+
## 7. Guarantees & limitations
|
|
255
|
+
|
|
256
|
+
- **Post-commit, in-order.** After-listeners fire strictly after commit, in
|
|
257
|
+
collection order, with the operation event last.
|
|
258
|
+
- **Veto is side-effect-free.** A throwing before-hook runs before any
|
|
259
|
+
transaction; nothing is written.
|
|
260
|
+
- **Listener isolation.** A throwing after-listener is logged and skipped; it
|
|
261
|
+
never rolls back the (already committed) mutation or blocks other listeners.
|
|
262
|
+
- **In-process, not persistent.** If the process crashes after commit but before
|
|
263
|
+
flush, those events are lost. This is an in-process event bus, not an
|
|
264
|
+
at-least-once outbox. If you need durable delivery, persist inside your
|
|
265
|
+
after-listener is *not* enough — use an external CDC/outbox.
|
|
266
|
+
- **"Handler resolve = post-commit" invariant.** Relies on every mutation
|
|
267
|
+
resolver `await`-ing all its writes (it does today). Never fire-and-forget an
|
|
268
|
+
un-awaited DB write inside a mutation.
|
|
269
|
+
- **Uneven transaction boundaries.** `receive*` wrap the transaction at the
|
|
270
|
+
resolver, `ship/transfer/cancel*/reclassify/merge/split` at the service, and
|
|
271
|
+
the `create/update` series have no explicit transaction (multiple awaits).
|
|
272
|
+
Events for the no-transaction operations fire after the writes complete, but
|
|
273
|
+
those operations were never atomic to begin with.
|
|
274
|
+
|
|
275
|
+
---
|
|
276
|
+
|
|
277
|
+
## 8. Public API
|
|
278
|
+
|
|
279
|
+
From the package root (`@rytass/wms-module-graphql`, re-exported from
|
|
280
|
+
`@rytass/wms-module-core`):
|
|
281
|
+
|
|
282
|
+
| Export | Kind | Purpose |
|
|
283
|
+
|---|---|---|
|
|
284
|
+
| `WmsMutationEvent({ event })` | decorator | Annotates a mutation (already applied across the library). |
|
|
285
|
+
| `WmsBeforeHookMap` / `WmsAfterListenerMap` | type | The `beforeHooks` / `afterListeners` option shapes. |
|
|
286
|
+
| `WmsEventModuleOptions` | type | `{ beforeHooks?, afterListeners? }` (part of `forRootAsync`). |
|
|
287
|
+
| `WmsBeforeHookContext` / `WmsAfterEventContext` | type | Hook/listener argument shapes. |
|
|
288
|
+
| `WmsDomainEvent` | type | `{ name, payload }`. |
|
|
289
|
+
| `WmsEventName` / `WmsOperationEventName` / `WmsStockEventName` | type | Event-name unions. |
|
|
290
|
+
| `WmsEventPayloadMap` | type | Event name -> payload mapping. |
|
|
291
|
+
| `WmsOperationEventPayload` / `WmsStockEventPayload` | type | Payload shapes. |
|
|
292
|
+
|
|
293
|
+
From the adapter subpath (`@rytass/wms-module-graphql/adapters/event-emitter`):
|
|
294
|
+
|
|
295
|
+
| Export | Kind | Purpose |
|
|
296
|
+
|---|---|---|
|
|
297
|
+
| `wmsEventEmitterBridgeProvider` | provider | Pass to `forRootAsync({ eventEmitterBridge })` to fan events onto `@nestjs/event-emitter`. |
|
|
298
|
+
|
|
299
|
+
`forRootAsync` accepts `beforeHooks`, `afterListeners` and `eventEmitterBridge`
|
|
300
|
+
on its single options object — all optional.
|
|
301
|
+
|
|
302
|
+
---
|
|
303
|
+
|
|
304
|
+
See also: [Authorization](./authorization.md) · [API Reference](./api-reference.md) · [Integration Guide](./integration-guide.md)
|