@flusys/nestjs-shared 9.1.2 → 9.2.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.
Files changed (40) hide show
  1. package/README.md +94 -18
  2. package/classes/api-controller.class.d.ts +1 -0
  3. package/classes/api-service.class.d.ts +7 -2
  4. package/classes/hybrid-cache.class.d.ts +17 -4
  5. package/constants/index.d.ts +1 -0
  6. package/constants/permissions.d.ts +9 -0
  7. package/fesm/183.js +7 -7
  8. package/fesm/{282.js → 47.js} +98 -158
  9. package/fesm/470.js +333 -382
  10. package/fesm/667.js +17 -17
  11. package/fesm/681.js +326 -129
  12. package/fesm/{948.js → 997.js} +46 -24
  13. package/fesm/chunks/123.js +17 -9
  14. package/fesm/chunks/37.js +5 -5
  15. package/fesm/classes/index.js +4 -3
  16. package/fesm/constants/index.js +19 -8
  17. package/fesm/decorators/index.js +120 -39
  18. package/fesm/dtos/index.js +1 -1
  19. package/fesm/entities/index.js +8 -8
  20. package/fesm/enums/index.js +2 -2
  21. package/fesm/exceptions/index.js +12 -12
  22. package/fesm/guards/index.js +201 -68
  23. package/fesm/index.js +50 -41
  24. package/fesm/interceptors/index.js +290 -590
  25. package/fesm/interfaces/index.js +17 -8
  26. package/fesm/middlewares/index.js +4 -4
  27. package/fesm/modules/index.js +2 -2
  28. package/fesm/pipes/index.js +8 -8
  29. package/fesm/utils/index.js +137 -478
  30. package/interceptors/idempotency.interceptor.d.ts +14 -2
  31. package/interceptors/slug.interceptor.d.ts +1 -0
  32. package/interfaces/permission-action-registry.interface.d.ts +1 -0
  33. package/interfaces/permission.interface.d.ts +1 -0
  34. package/modules/shared-permission-cache/shared-permission-cache.service.d.ts +3 -1
  35. package/modules/utils/utils.service.d.ts +6 -6
  36. package/package.json +2 -2
  37. package/utils/date-time.util.d.ts +0 -1
  38. package/utils/event-pattern.util.d.ts +1 -0
  39. package/utils/permission-cache-key.util.d.ts +3 -0
  40. package/utils/query-helpers.util.d.ts +2 -2
package/README.md CHANGED
@@ -164,8 +164,28 @@ Generated POST-only endpoints: `/insert`, `/insert-many`, `/get-all`, `/get/:id`
164
164
 
165
165
  Security levels: `'public'` (no auth), `'jwt'` (token only), `'permission'` (token + action check).
166
166
 
167
+ An endpoint left out of a per-endpoint `security` map is never public: `insertMany` / `updateMany` take
168
+ the security of `insert` / `update`, `getByIds` / `getByFilter` that of `getById` (else `getAll`), and
169
+ `bulkUpsert` requires both `insert` and `update` (their permissions combined with AND). Anything still
170
+ unset - including a controller with no `security` option at all - requires a JWT. List an endpoint as
171
+ `'public'` explicitly to open it.
172
+
173
+ `PermissionGuard` fails closed on an empty requirement: `@RequirePermission()` with no codes, `''`, `[]`
174
+ or `{ permissions: [] }` is a 403 for every user (and a 401 without one), never a pass.
175
+
176
+ `IdempotencyInterceptor` (on `insert` / `insert-many`) replays the stored response for a repeated
177
+ `X-Idempotency-Key` only to the same tenant, user and endpoint, and releases the key when the request
178
+ fails, so a retry after an error is processed again rather than answered with 409.
179
+
167
180
  Bodies of `insert` / `insert-many` / `update` / `update-many` / `bulk-upsert` are validated against the concrete Create/Update DTO by `DtoBodyValidationPipe` (`@flusys/nestjs-shared/pipes`, same `DEFAULT_VALIDATION_OPTIONS` as the global pipe: whitelist + forbidNonWhitelisted, no implicit conversion), because a generic `@Body() dto: T` reflects as `Object` and the global pipe skips it. Audit fields are never taken from the client: `SetCreatedByOnBody` / `SetUpdateByOnBody` / `SetDeletedByOnBody` drop any client-sent `createdById` / `updatedById` / `deletedById` and stamp the authenticated user's id (nothing without a user), and the pipe keeps only the audit key the endpoint's interceptor stamps. A `slug` the DTO does not declare is the `Slug` interceptor's and is kept.
168
181
 
182
+ ### Company scoping
183
+
184
+ `applyCompanyFilter(query, { isCompanyFeatureEnabled, entityAlias }, user)`, `buildCompanyWhereCondition(where, enabled, user)`
185
+ and `validateCompanyOwnership(entity, user, enabled, name)` (`@flusys/nestjs-shared/utils`) keep a user to their own company
186
+ when the company feature is on. A logged-in user without a `companyId` matches nothing (and may not use another company's
187
+ row) - never every company's data. Passing no user at all (`null` / `undefined`) is a system call and is left unscoped.
188
+
169
189
  ### Savepoints in a shared transaction
170
190
 
171
191
  `withSavepoint(queryRunner, fn)` (`@flusys/nestjs-shared/utils`) runs `fn` inside a savepoint of an
@@ -259,33 +279,88 @@ All exceptions produce: `{ success: false, message, messageKey,messageVariables:
259
279
 
260
280
  ## 7. Hybrid Cache
261
281
 
262
- ```typescript
263
- import { HybridCache } from '@flusys/nestjs-shared/classes';
282
+ `CacheModule.forRoot(isGlobal = true, memoryTtl = 60_000, memorySize = 5000)` provides one `HybridCache` under `CACHE_INSTANCE`. The store is chosen by `USE_CACHE_LABEL`:
283
+
284
+ | `USE_CACHE_LABEL` | Data store | Stamps / claims | Use when |
285
+ | ------------------ | ------------------------------------------ | --------------------------- | ------------------------------------------ |
286
+ | `memory` (default) | in-process LRU | in-process | **one instance only** - nothing is shared |
287
+ | `redis` | Redis (`REDIS_URL`) | Redis (`SET NX`) | several instances |
288
+ | `hybrid` | in-process L1 + Redis L2 | Redis (`SET NX`) | several instances, hot immutable reads |
289
+
290
+ `memory` mode is single-instance only: invalidations, claims and one-time values live in the process, so a second instance never sees them. Run more than one instance with `redis` or `hybrid`.
264
291
 
292
+ ### API
293
+
294
+ | Method | Meaning |
295
+ | --------------------------------------- | ----------------------------------------------------------------------------------------------------------- |
296
+ | `get(key, { skipMemory? })` | Read; L1 first in `hybrid` unless `skipMemory`. Only `undefined` is a miss. |
297
+ | `set(key, value, ttl?, { skipMemory? })`| Write with `ttl` ms, else the default (`memoryTtl`) - on every layer, Redis included, so nothing lives forever. |
298
+ | `del(key)` | Delete from every layer (other instances' L1 copies are **not** reached). |
299
+ | `getStamp(name)` / `bumpStamp(name)` | Version stamp: created at random when absent, moved by `bumpStamp` atomically for every instance. |
300
+ | `claim(name, ttl)` / `releaseClaim(name)` | Atomic first-wins lock (Redis `SET NX PX`); `true` only for the first caller while it lives. |
301
+ | `take(key, ttl)` | Atomic read-and-delete of a one-time value: exactly one caller across all instances receives it. |
302
+
303
+ ### Rules
304
+
305
+ 1. **L1 values must be immutable for their key.** In `hybrid` mode the in-process layer of other instances is never invalidated, so `del` or an overwrite does not reach them. A key whose value changes in place - sessions, revocation markers, one-time state, counters, flags - must use `{ skipMemory: true }` on **both** `get` and `set`.
306
+ 2. **One-time values use `take`**, never `get` + `del` (two concurrent requests would both read the value). The claim behind `take` is per value, so a fresh value stored under the same key (a resent code) can be taken again.
307
+ 3. **Locks and de-duplication use `claim`** and release it when done; the ttl bounds how long a crashed instance holds it.
308
+ 4. **Invalidate by stamp, after commit.** Embed `getStamp()` in data keys and `bumpStamp()` to invalidate - never track key lists. Bump **after** the transaction commits: a bump inside it lets a concurrent reader re-cache old rows under the new stamp.
309
+
310
+ ```typescript
265
311
  @Injectable()
266
312
  export class MyService {
267
- constructor(@Inject('CACHE_INSTANCE') private cache: HybridCache) {}
268
-
269
- async getData(key: string) {
270
- const cached = await this.cache.get(key);
271
- if (cached) return cached;
272
- const data = await this.fetchFromDb();
273
- await this.cache.set(key, data, 3600); // TTL in seconds
274
- return data;
275
- }
313
+ constructor(@Inject(CACHE_INSTANCE) private readonly cache: HybridCache) {}
276
314
 
277
- async invalidate(key: string) {
278
- await this.cache.delete(key);
315
+ async consumeResetToken(tokenHash: string) {
316
+ return this.cache.take<IResetState>(`reset:${tokenHash}`, 15 * 60_000);
279
317
  }
280
- async invalidatePrefix(prefix: string) {
281
- await this.cache.deleteByPrefix(prefix);
318
+
319
+ async runOnce(jobId: string, work: () => Promise<void>) {
320
+ if (!(await this.cache.claim(`job:${jobId}`, 60_000))) return;
321
+ try {
322
+ await work();
323
+ } finally {
324
+ await this.cache.releaseClaim(`job:${jobId}`);
325
+ }
282
326
  }
283
327
  }
284
328
  ```
285
329
 
286
- `CacheModule.forRoot(true)` connects to Redis automatically when `REDIS_URL` is set; otherwise uses in-memory only.
330
+ ### ApiService caching
331
+
332
+ With `isCacheable = true`, `findById` and `getAll` are cached under `[tenant_<id>_]entity_<name>_<id_x|all>_<stamp>_<sha256(query)>`:
333
+
334
+ - The stamp (one per entity in `cacheDependencies()`, joined) is read **before** the query, so a write that commits mid-read moves the stamp and the stale result lands under a key no later read asks for.
335
+ - Cache failures on the read path (stamp, get, set) are logged and the read goes to the database.
336
+ - `insert` / `insertMany` / `update` / `updateMany` / `bulkUpsert` / `delete` call `invalidateCache()` after commit - whether the service is cacheable or not, because another service may cache the same entity. A failed invalidation is logged, never fails the committed write.
337
+ - `after*Operation` hooks run **before** commit - never invalidate in them.
338
+
339
+ ```typescript
340
+ // A cached read that joins or embeds another entity must depend on it
341
+ protected override cacheDependencies(): string[] {
342
+ return [this.entityName, TASK_MANAGER_EVENT_ENTITIES.TASK];
343
+ }
344
+
345
+ // A service that writes another entity directly (repository, query runner, cascade) bumps it after commit
346
+ await this.utilsService.clearCache(OTHER_ENTITY_NAME, this.cacheManager, this.cacheTenant());
347
+ ```
348
+
349
+ `UtilsService`: `getCacheStamp(cache, entityNames, tenantId?)`, `getCacheKey(entityName, params, entityId, tenantId, stamp)`, `clearCache(entityName, cache, tenantId?)` (bumps the stamp; logs instead of throwing).
350
+
351
+ A cached result must depend only on the SQL it ran: anything else (current time, config, the user beyond what the query filters on, feature flags) belongs in the key or the read must not be cached.
352
+
353
+ **Multi-tenant keys:** one cache serves every tenant, so `ApiService` prefixes its keys and stamps with the current tenant (`cacheTenant()`), taken from a tenant-aware DataSource provider (`isMultiTenant()` / `getCurrentTenantId()`, which `MultiTenantDataSourceService` implements). A single database is never prefixed. Code that calls `UtilsService` itself must pass the same tenant: `cacheTenantId(dataSourceProvider)` from `@flusys/nestjs-shared/utils`, or `this.cacheTenant()` inside an `ApiService`.
354
+
355
+ ### Permission cache
356
+
357
+ A user's codes are cached under `[tenant:<id>:]<format>:v:<user stamp>` - the scope key from `buildPermissionCacheKey` (user, or company + branch with `enableCompanyFeature`) plus one stamp per user. `resolvePermissionCacheKey(cache, options, config)` gives the key (resolve it before reading grants from the database, write under that same key); `invalidatePermissionCache(cache, userId, config)` bumps the stamp and drops every company / branch scope of the user on every instance. The tenant prefix is added only when `config.multiTenant` is set - a single database ignores a tenant header.
358
+
359
+ `SharedPermissionCacheService.getPermissions` reads that key and, on a miss (first request, expiry, invalidation), falls back to `PERMISSION_RESOLVER` when it is provided (nestjs-iam); without one, a miss is "no permissions". `setPermissions` writes the current key; `clearPermissions` bumps the user stamp.
360
+
361
+ ### Idempotency
287
362
 
288
- **Multi-tenant keys:** one cache serves every tenant, so `ApiService` prefixes its keys with the current tenant (`cacheTenant()`), taken from a tenant-aware DataSource provider (`isMultiTenant()` / `getCurrentTenantId()`, which `MultiTenantDataSourceService` implements). A single database is never prefixed. Code that clears or reads these keys through `UtilsService` itself must pass the same tenant: `cacheTenantId(dataSourceProvider)` from `@flusys/nestjs-shared/utils`, or `this.cacheTenant()` inside an `ApiService`.
363
+ `IdempotencyInterceptor` keys `X-Idempotency-Key` by tenant, user and endpoint. A stored response is replayed (read with `skipMemory`); otherwise the request `claim`s the key (409 `system.duplicate.request` while another request holds it), stores its response for 24 h with `skipMemory`, and releases the claim on success and on error so a failed request can be retried.
289
364
 
290
365
  ## 8. Hybrid Event Bus
291
366
 
@@ -412,7 +487,8 @@ so `enabled: false` silences the module everywhere. `delete` and `restore` carry
412
487
  there is no row left to attach.
413
488
 
414
489
  Payloads are walked before they leave the process: credential-like fields become
415
- `[REDACTED]`, a relation that loops back becomes `[CIRCULAR]`, anything past 8 levels deep
490
+ `[REDACTED]` (matched case and separator blind - `apiKey`, `api_key`, `X-API-KEY`,
491
+ `private-key` alike; `@LogAction({ includeParams: true })` uses the same redaction), a relation that loops back becomes `[CIRCULAR]`, anything past 8 levels deep
416
492
  becomes `[TRUNCATED]`, and a Buffer becomes `[BINARY]`. Saved TypeORM rows with
417
493
  bidirectional relations are therefore safe to publish. The `config` blob on a
418
494
  `storageConfig` or `emailConfig` row is redacted whole - S3, Azure, SFTP and SMTP
@@ -25,6 +25,7 @@ export declare function createApiController<CreateDtoT extends object, UpdateDto
25
25
  readonly enabledEndpoints: ApiEndpoint[] | "all";
26
26
  service: ServiceT;
27
27
  isEnabled(endpoint: ApiEndpoint): boolean;
28
+ assertEnabled(endpoint: ApiEndpoint): void;
28
29
  insert(addDto: CreateDtoT, user: ILoggedUserInfo | null): Promise<SingleResponseDto<ResponseDtoT>>;
29
30
  insertMany(addDto: CreateDtoT[], user: ILoggedUserInfo | null): Promise<BulkResponseDto<ResponseDtoT>>;
30
31
  getById(id: string, body: GetByIdBodyDto, user: ILoggedUserInfo | null): Promise<SingleResponseDto<ResponseDtoT>>;
@@ -23,9 +23,16 @@ export declare abstract class ApiService<CreateDtoT extends object, UpdateDtoT e
23
23
  private repositoryInitialized;
24
24
  private readonly _entity;
25
25
  private readonly _dataSourceProvider;
26
+ private readonly logger;
26
27
  constructor(entityName: string, cacheManager: HybridCache, utilsService: UtilsService, _loggerName: string, isCacheable: boolean | undefined, moduleName: string, dataSourceProvider: IDataSourceProvider, entity: EntityTarget<EntityT>);
27
28
  protected getDataSourceProvider(): IDataSourceProvider;
28
29
  protected cacheTenant(): string | undefined;
30
+ protected cacheDependencies(): string[];
31
+ protected bypassListCache(_filterAndPaginationDto: FilterAndPaginationDto): boolean;
32
+ invalidateCache(): Promise<void>;
33
+ private readCacheStamp;
34
+ private readCached;
35
+ private writeCached;
29
36
  protected ensureDataSourceRepository(): Promise<void>;
30
37
  protected resolveRepositories(entities: EntityTarget<any>[]): Promise<Repository<any>[]>;
31
38
  insert(dto: CreateDtoT, user: ILoggedUserInfo | null): Promise<InterfaceT>;
@@ -42,8 +49,6 @@ export declare abstract class ApiService<CreateDtoT extends object, UpdateDtoT e
42
49
  delete(option: DeleteDto, user: ILoggedUserInfo | null): Promise<null>;
43
50
  protected publishLifecycleEvents(groups: IEventGroup<EntityT>[], user: ILoggedUserInfo | null): Promise<void>;
44
51
  protected publishDomainAction(action: string, input?: Omit<IPublishDomainEventInput, 'module' | 'entity' | 'action'>): Promise<void>;
45
- clearCacheForAll(): Promise<void>;
46
- clearCacheForId(entities: EntityT[]): Promise<void>;
47
52
  private handleError;
48
53
  private ensureArray;
49
54
  private executeInTransaction;
@@ -1,10 +1,23 @@
1
+ export type CacheMode = 'memory' | 'redis' | 'hybrid';
2
+ export interface ICacheAccessOptions {
3
+ skipMemory?: boolean;
4
+ }
1
5
  export declare class HybridCache {
6
+ private readonly mode;
7
+ private readonly defaultTtl;
2
8
  private memory?;
3
9
  private redis?;
10
+ private redisStore?;
11
+ private localAtomic?;
4
12
  constructor(memoryTtl?: number, memorySize?: number);
5
- get<T>(key: string): Promise<T | undefined>;
6
- set<T>(key: string, value: T, ttl?: number): Promise<void>;
13
+ getMode(): CacheMode;
14
+ get<T>(key: string, options?: ICacheAccessOptions): Promise<T | undefined>;
15
+ set<T>(key: string, value: T, ttl?: number, options?: ICacheAccessOptions): Promise<void>;
7
16
  del(key: string): Promise<void>;
8
- reset(): Promise<void>;
9
- resetL2(): Promise<void>;
17
+ getStamp(name: string): Promise<string>;
18
+ bumpStamp(name: string): Promise<void>;
19
+ claim(name: string, ttl: number): Promise<boolean>;
20
+ releaseClaim(name: string): Promise<void>;
21
+ take<T>(key: string, ttl: number): Promise<T | undefined>;
22
+ private rawKey;
10
23
  }
@@ -8,6 +8,7 @@ export declare const EVENT_BUS_MODULE_OPTIONS = "EVENT_BUS_MODULE_OPTIONS";
8
8
  export declare const IDEMPOTENCY_KEY_HEADER = "x-idempotency-key";
9
9
  export declare const REQUEST_ID_HEADER = "x-request-id";
10
10
  export declare const CLIENT_TYPE_HEADER = "x-client-type";
11
+ export declare const TENANT_ID_PATTERN: RegExp;
11
12
  export declare const PERMISSIONS_CACHE_PREFIX = "my-permissions";
12
13
  export declare const IDEMPOTENCY_CACHE_PREFIX = "idempotency";
13
14
  export declare const DEFAULT_EVENT_EXCHANGE = "flusys.events";
@@ -51,6 +51,9 @@ export declare const COMPANY_ACTION_PERMISSIONS: {
51
51
  readonly READ: "company-action.read";
52
52
  readonly ASSIGN: "company-action.assign";
53
53
  };
54
+ export declare const CROSS_COMPANY_PERMISSIONS: {
55
+ readonly ASSIGN: "cross-company.assign";
56
+ };
54
57
  export declare const FILE_PERMISSIONS: {
55
58
  readonly CREATE: "file.create";
56
59
  readonly READ: "file.read";
@@ -239,6 +242,9 @@ export declare const PERMISSIONS: {
239
242
  readonly READ: "company-action.read";
240
243
  readonly ASSIGN: "company-action.assign";
241
244
  };
245
+ readonly CROSS_COMPANY: {
246
+ readonly ASSIGN: "cross-company.assign";
247
+ };
242
248
  readonly FILE: {
243
249
  readonly CREATE: "file.create";
244
250
  readonly READ: "file.read";
@@ -258,6 +264,9 @@ export declare const PERMISSIONS: {
258
264
  readonly UPDATE: "storage-config.update";
259
265
  readonly DELETE: "storage-config.delete";
260
266
  };
267
+ readonly UPLOAD_CONFIG: {
268
+ readonly DELETE: "upload-config.delete";
269
+ };
261
270
  readonly EMAIL_CONFIG: {
262
271
  readonly CREATE: "email-config.create";
263
272
  readonly READ: "email-config.read";
package/fesm/183.js CHANGED
@@ -4,7 +4,7 @@ import * as __rspack_external_class_validator_7b99ef4d from "class-validator";
4
4
  export const __rspack_esm_id = 183;
5
5
  export const __rspack_esm_ids = [183];
6
6
  export const __webpack_modules__ = {
7
- 746(__unused_rspack_module, __webpack_exports__, __webpack_require__) {
7
+ 3746(__unused_rspack_module, __webpack_exports__, __webpack_require__) {
8
8
 
9
9
  // EXPORTS
10
10
  __webpack_require__.d(__webpack_exports__, {
@@ -24,9 +24,9 @@ __webpack_require__.d(__webpack_exports__, {
24
24
  });
25
25
 
26
26
  // EXTERNAL MODULE: external "@nestjs/swagger"
27
- var swagger_ = __webpack_require__(176);
27
+ var swagger_ = __webpack_require__(6176);
28
28
  // EXTERNAL MODULE: external "class-validator"
29
- var external_class_validator_ = __webpack_require__(421);
29
+ var external_class_validator_ = __webpack_require__(5421);
30
30
  ;// CONCATENATED MODULE: ./projects/nestjs-shared/src/dtos/delete.dto.ts
31
31
  function _ts_decorate(decorators, target, key, desc) {
32
32
  var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
@@ -106,7 +106,7 @@ _ts_decorate([
106
106
  ], DeleteDto.prototype, "deletedById", void 0);
107
107
 
108
108
  // EXTERNAL MODULE: external "class-transformer"
109
- var external_class_transformer_ = __webpack_require__(812);
109
+ var external_class_transformer_ = __webpack_require__(5812);
110
110
  ;// CONCATENATED MODULE: ./projects/nestjs-shared/src/dtos/pagination.dto.ts
111
111
  function pagination_dto_ts_decorate(decorators, target, key, desc) {
112
112
  var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
@@ -777,19 +777,19 @@ MessageResponseDto = response_payload_dto_ts_decorate([
777
777
 
778
778
 
779
779
  },
780
- 176(module) {
780
+ 6176(module) {
781
781
 
782
782
  module.exports = __rspack_external__nestjs_swagger_7cd450b5;
783
783
 
784
784
 
785
785
  },
786
- 812(module) {
786
+ 5812(module) {
787
787
 
788
788
  module.exports = __rspack_external_class_transformer_c5a479fb;
789
789
 
790
790
 
791
791
  },
792
- 421(module) {
792
+ 5421(module) {
793
793
 
794
794
  module.exports = __rspack_external_class_validator_7b99ef4d;
795
795