From bab7aecf5400954c6f21c1e99434174b892cde1d Mon Sep 17 00:00:00 2001 From: "Mickael N." Date: Tue, 26 May 2026 20:00:25 +0200 Subject: [PATCH 1/2] =?UTF-8?q?feat(typing):=206=20upstream=20improvements?= =?UTF-8?q?=20=E2=80=94=20BeforeRegisterContext,=20TExtra,=20BodyDTO=20gen?= =?UTF-8?q?erics,=20customRoutes=20req/interceptors,=20WS=20customEvents?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #5 — BeforeRegisterContext = { hashedPassword: string }: DynamicApiRegisterOptions.beforeSaveCallback is now fully typed. Backward compatible via TS contravariance. #2 — DynamicApiRegisterOptions + DynamicApiAuthOptions + DynamicApiForRootOptions: extra request fields (deviceToken, familyId...) are typeable without as-any. Defaults to Record. #4 — BodyDTO generic on all write route configs (CreateOne/Many, UpdateOne/Many, ReplaceOne, DuplicateOne/Many). defineXxxCallback helpers (defineUpdateCallback() etc.) eliminate as-never casts when beforeSaveCallback is defined outside the config object. #3 — CustomRouteConfig.useInterceptors (route-level, e.g. FileInterceptor); CustomRouteContext.req (optional, HTTP only) gives raw request access in handlers. #6 — CustomSocketEventConfig + DynamicApiWebSocketSetupOptions.customEvents: declarative socket.on() handlers with optional predicate guard, debug logging, async error catching. DynamicApiWsConfigStore.customEvents replaces manual socket.on() in onConnection. All changes fully backward compatible (optional fields, defaulted generics). 1380 unit tests pass, 0 regressions. --- .../src/adapters/socket-adapter.spec.ts | 135 ++++++++++++ .../src/adapters/socket-adapter.ts | 31 ++- .../src/helpers/socket-config.helper.spec.ts | 19 ++ .../src/helpers/socket-config.helper.ts | 1 + .../src/helpers/ws-config.store.ts | 6 +- .../dynamic-api-custom-route.interface.ts | 47 ++++- .../dynamic-api-options.interface.ts | 4 +- ...dynamic-api-route-config.interface.spec.ts | 189 +++++++++++++++++ .../dynamic-api-route-config.interface.ts | 197 ++++++++++++++++-- ...ice-before-save-callback.interface.spec.ts | 67 ++++++ ...-service-before-save-callback.interface.ts | 22 ++ .../dynamic-api-web-socket.interface.ts | 82 +++++++- .../auth/interfaces/auth-options.interface.ts | 32 ++- .../create-custom-route-controller.spec.ts | 48 ++++- .../custom/create-custom-route-controller.ts | 7 + 15 files changed, 851 insertions(+), 36 deletions(-) diff --git a/libs/dynamic-api/src/adapters/socket-adapter.spec.ts b/libs/dynamic-api/src/adapters/socket-adapter.spec.ts index 6f589049..bd3f7ae0 100644 --- a/libs/dynamic-api/src/adapters/socket-adapter.spec.ts +++ b/libs/dynamic-api/src/adapters/socket-adapter.spec.ts @@ -143,5 +143,140 @@ describe('SocketAdapter', () => { expect.any(String), ); }); + + describe('customEvents', () => { + it('registers socket.on for each customEvent on connection', () => { + const handler = jest.fn(); + const eventSocket = { + id: 'sock-ev', + handshake: { auth: {}, query: {} }, + on: jest.fn(), + }; + + DynamicApiWsConfigStore.customEvents = [ + { name: 'voice-call', handler }, + { name: 'admin-action', handler }, + ]; + + connectionHandler(eventSocket); + + expect(eventSocket.on).toHaveBeenCalledTimes(2); + expect(eventSocket.on).toHaveBeenCalledWith('voice-call', expect.any(Function)); + expect(eventSocket.on).toHaveBeenCalledWith('admin-action', expect.any(Function)); + }); + + it('calls the event handler with payload and user', () => { + const handler = jest.fn(); + let capturedListener: ((payload: unknown) => void) | undefined; + + const eventSocket = { + id: 'sock-ev2', + handshake: { auth: {}, query: {} }, + on: jest.fn((_name: string, listener: (payload: unknown) => void) => { + capturedListener = listener; + }), + }; + + DynamicApiWsConfigStore.customEvents = [{ name: 'test-event', handler }]; + + connectionHandler(eventSocket); + capturedListener!({ data: 'hello' }); + + expect(handler).toHaveBeenCalledWith(eventSocket, { data: 'hello' }, undefined); + }); + + it('blocks the event handler when predicate returns false', () => { + const handler = jest.fn(); + const predicate = jest.fn().mockReturnValue(false); + let capturedListener: ((payload: unknown) => void) | undefined; + + const eventSocket = { + id: 'sock-pred', + handshake: { auth: {}, query: {} }, + on: jest.fn((_name: string, listener: (payload: unknown) => void) => { + capturedListener = listener; + }), + }; + + DynamicApiWsConfigStore.customEvents = [{ name: 'restricted', handler, predicate }]; + + connectionHandler(eventSocket); + capturedListener!({ data: 'blocked' }); + + expect(predicate).toHaveBeenCalledWith(undefined); + expect(handler).not.toHaveBeenCalled(); + }); + + it('calls the event handler when predicate returns true', () => { + const handler = jest.fn(); + const predicate = jest.fn().mockReturnValue(true); + let capturedListener: ((payload: unknown) => void) | undefined; + + const eventSocket = { + id: 'sock-pred2', + handshake: { auth: {}, query: {} }, + on: jest.fn((_name: string, listener: (payload: unknown) => void) => { + capturedListener = listener; + }), + }; + + DynamicApiWsConfigStore.customEvents = [{ name: 'allowed', handler, predicate }]; + + connectionHandler(eventSocket); + capturedListener!({ data: 'ok' }); + + expect(handler).toHaveBeenCalledWith(eventSocket, { data: 'ok' }, undefined); + }); + + it('catches async custom event handler errors', async () => { + const error = new Error('event error'); + const handler = jest.fn().mockRejectedValue(error); + const spyError = jest.spyOn(adapter['logger'], 'error').mockImplementation(() => {}); + let capturedListener: ((payload: unknown) => void) | undefined; + + const eventSocket = { + id: 'sock-err', + handshake: { auth: {}, query: {} }, + on: jest.fn((_name: string, listener: (payload: unknown) => void) => { + capturedListener = listener; + }), + }; + + DynamicApiWsConfigStore.customEvents = [{ name: 'failing-event', handler }]; + + connectionHandler(eventSocket); + capturedListener!({}); + + await new Promise((r) => setTimeout(r, 10)); + + expect(spyError).toHaveBeenCalledWith( + expect.stringContaining("customEvent 'failing-event' handler error"), + expect.any(String), + ); + }); + + it('logs debug warning when predicate blocks event and debug is true', () => { + DynamicApiWsConfigStore.debug = true; + const spyWarn = jest.spyOn(adapter['logger'], 'warn').mockImplementation(() => {}); + const handler = jest.fn(); + const predicate = jest.fn().mockReturnValue(false); + let capturedListener: ((payload: unknown) => void) | undefined; + + const eventSocket = { + id: 'sock-dbg', + handshake: { auth: {}, query: {} }, + on: jest.fn((_name: string, listener: (payload: unknown) => void) => { + capturedListener = listener; + }), + }; + + DynamicApiWsConfigStore.customEvents = [{ name: 'guarded', handler, predicate }]; + + connectionHandler(eventSocket); + capturedListener!({}); + + expect(spyWarn).toHaveBeenCalledWith(expect.stringContaining('blocked by predicate')); + }); + }); }); }); diff --git a/libs/dynamic-api/src/adapters/socket-adapter.ts b/libs/dynamic-api/src/adapters/socket-adapter.ts index 30ea4de7..63af3c72 100644 --- a/libs/dynamic-api/src/adapters/socket-adapter.ts +++ b/libs/dynamic-api/src/adapters/socket-adapter.ts @@ -27,8 +27,8 @@ export class SocketAdapter extends IoAdapter { } private handleConnection(socket: ExtendedSocket): void { - const { debug, jwtSecret, onConnection } = DynamicApiWsConfigStore; - let user: any; + const { debug, jwtSecret, onConnection, customEvents } = DynamicApiWsConfigStore; + let user: unknown; if (jwtSecret) { const token = (socket.handshake?.auth?.token @@ -51,7 +51,8 @@ export class SocketAdapter extends IoAdapter { } if (debug) { - this.logger.log(`[WS] connection – socket=${socket.id}, user=${user?.id ?? 'anonymous'}`); + const userId = (user as { id?: string })?.id ?? 'anonymous'; + this.logger.log(`[WS] connection – socket=${socket.id}, user=${userId}`); } if (onConnection) { @@ -64,5 +65,29 @@ export class SocketAdapter extends IoAdapter { }); } } + + // ─── Register declarative custom event handlers ────────────────────────── + for (const eventConfig of customEvents) { + socket.on(eventConfig.name, (payload: unknown) => { + if (eventConfig.predicate && !eventConfig.predicate(user)) { + if (debug) { + this.logger.warn(`[WS] event=${eventConfig.name} blocked by predicate for socket=${socket.id}`); + } + return; + } + + const result = eventConfig.handler(socket, payload, user); + if (result instanceof Promise) { + result.catch((err) => { + const message = err instanceof Error ? err.message : String(err); + const stack = err instanceof Error ? err.stack : undefined; + this.logger.error( + `customEvent '${eventConfig.name}' handler error for socket ${socket.id}: ${message}`, + stack, + ); + }); + } + }); + } } } diff --git a/libs/dynamic-api/src/helpers/socket-config.helper.spec.ts b/libs/dynamic-api/src/helpers/socket-config.helper.spec.ts index 6804d293..7c600e28 100644 --- a/libs/dynamic-api/src/helpers/socket-config.helper.spec.ts +++ b/libs/dynamic-api/src/helpers/socket-config.helper.spec.ts @@ -44,6 +44,25 @@ describe('SocketConfigHelper', () => { expect(DynamicApiWsConfigStore.debug).toBe(true); expect(DynamicApiWsConfigStore.onConnection).toBe(onConnection); expect(DynamicApiWsConfigStore.jwtSecret).toBe('test-jwt-secret'); + expect(DynamicApiWsConfigStore.customEvents).toEqual([]); + }); + + it('should populate customEvents in the config store', () => { + const handler = jest.fn(); + const customEvents = [ + { name: 'voice-call', handler }, + { name: 'admin-action', predicate: jest.fn(), handler }, + ]; + + enableDynamicAPIWebSockets(fakeApp, { customEvents }); + + expect(DynamicApiWsConfigStore.customEvents).toBe(customEvents); + }); + + it('should default customEvents to empty array when not provided', () => { + enableDynamicAPIWebSockets(fakeApp); + + expect(DynamicApiWsConfigStore.customEvents).toEqual([]); }); it('should accept a number (deprecated) and warn', () => { diff --git a/libs/dynamic-api/src/helpers/socket-config.helper.ts b/libs/dynamic-api/src/helpers/socket-config.helper.ts index da716528..5f74d2ba 100644 --- a/libs/dynamic-api/src/helpers/socket-config.helper.ts +++ b/libs/dynamic-api/src/helpers/socket-config.helper.ts @@ -44,6 +44,7 @@ function enableDynamicAPIWebSockets( // Populate the static config store DynamicApiWsConfigStore.onConnection = resolvedOptions.onConnection; + DynamicApiWsConfigStore.customEvents = resolvedOptions.customEvents ?? []; DynamicApiWsConfigStore.debug = resolvedOptions.debug ?? false; // Read jwtSecret from global state (may be undefined when auth is not configured) diff --git a/libs/dynamic-api/src/helpers/ws-config.store.ts b/libs/dynamic-api/src/helpers/ws-config.store.ts index 98b61c5b..3e577d95 100644 --- a/libs/dynamic-api/src/helpers/ws-config.store.ts +++ b/libs/dynamic-api/src/helpers/ws-config.store.ts @@ -1,4 +1,4 @@ -import { ExtendedSocket } from '../interfaces'; +import { CustomSocketEventConfig, ExtendedSocket } from '../interfaces'; /** * Static store for WebSocket configuration values. @@ -6,13 +6,15 @@ import { ExtendedSocket } from '../interfaces'; * @deprecated Internal API — will be removed from public exports in v5. */ export class DynamicApiWsConfigStore { - static onConnection: ((socket: ExtendedSocket, user?: any) => void | Promise) | undefined; + static onConnection: ((socket: ExtendedSocket, user?: unknown) => void | Promise) | undefined; + static customEvents: CustomSocketEventConfig[] = []; static debug = false; static jwtSecret: string | undefined; /** Reset all values — useful for testing. */ static reset(): void { this.onConnection = undefined; + this.customEvents = []; this.debug = false; this.jwtSecret = undefined; } diff --git a/libs/dynamic-api/src/interfaces/dynamic-api-custom-route.interface.ts b/libs/dynamic-api/src/interfaces/dynamic-api-custom-route.interface.ts index 3105d9f3..627019fb 100644 --- a/libs/dynamic-api/src/interfaces/dynamic-api-custom-route.interface.ts +++ b/libs/dynamic-api/src/interfaces/dynamic-api-custom-route.interface.ts @@ -1,7 +1,8 @@ -import { CanActivate, Type, ValidationPipeOptions } from '@nestjs/common'; +import { CanActivate, NestInterceptor, Type, ValidationPipeOptions } from '@nestjs/common'; import { Model } from 'mongoose'; import { BaseEntity } from '../models'; import { AbilityPredicate, PredicateBehavior } from './dynamic-api-ability.interface'; +import { DynamicApiRequest } from './dynamic-api-request.interface'; import { Mappable } from './dynamic-api-route-dtos-bundle.type'; import { DynamicApiWebSocketOptions } from './dynamic-api-web-socket.interface'; @@ -34,6 +35,26 @@ interface CustomRouteContext< body: Body; /** Parsed query string object. */ query: Query; + /** + * The raw HTTP request object. + * + * Available **only** in HTTP context (not in WebSocket/gateway handlers, where it is `undefined`). + * + * Useful for accessing Multer file uploads (via `FileInterceptor`) or other low-level + * request properties that are not available through the typed `body`/`query` fields. + * + * @example — reading a Multer file added by `FileInterceptor` + * ```typescript + * import type { DynamicApiRequest } from 'mongodb-dynamic-api'; + * + * const handleUpload = async ({ req }) => { + * interface UploadRequest extends DynamicApiRequest { file?: Express.Multer.File } + * const file = (req as UploadRequest)?.file; + * // ... + * }; + * ``` + */ + req?: DynamicApiRequest; } /** @@ -139,6 +160,30 @@ interface CustomRouteConfig< */ presenter?: Type & Partial>; }; + + /** + * Route-level NestJS interceptors applied **only** to this custom route. + * + * Use this to attach per-route interceptors such as `FileInterceptor` for multipart + * uploads without touching the controller-level `useInterceptors`. + * + * @example — multipart file upload via `FileInterceptor` + * ```typescript + * import { FileInterceptor } from '@nestjs/platform-express'; + * + * { + * method: 'POST', + * path: ':id/attachments/upload', + * useInterceptors: [FileInterceptor('file', { limits: { fileSize: 10 * 1024 * 1024 } })], + * handler: async ({ req }) => { + * interface UploadRequest extends DynamicApiRequest { file?: Express.Multer.File } + * const file = (req as UploadRequest).file; + * // process file ... + * }, + * } + * ``` + */ + useInterceptors?: Type[]; } export type { HttpMethod, CustomRouteContext, CustomRouteConfig }; diff --git a/libs/dynamic-api/src/interfaces/dynamic-api-options.interface.ts b/libs/dynamic-api/src/interfaces/dynamic-api-options.interface.ts index 8f704c9f..546c282d 100644 --- a/libs/dynamic-api/src/interfaces/dynamic-api-options.interface.ts +++ b/libs/dynamic-api/src/interfaces/dynamic-api-options.interface.ts @@ -12,10 +12,10 @@ import { DynamicApiWebSocketOptions } from './dynamic-api-web-socket.interface'; /** @deprecated Internal API — will be removed from public exports in v5. */ const DYNAMIC_API_GLOBAL_STATE = Symbol('DYNAMIC_API_GLOBAL_STATE'); -interface DynamicApiForRootOptions { +interface DynamicApiForRootOptions> { useGlobalCache?: boolean; cacheOptions?: DynamicApiCacheOptions; - useAuth?: DynamicApiAuthOptions; + useAuth?: DynamicApiAuthOptions; routesConfig?: Partial; webSocket?: DynamicApiWebSocketOptions; broadcastGatewayOptions?: GatewayMetadata; diff --git a/libs/dynamic-api/src/interfaces/dynamic-api-route-config.interface.spec.ts b/libs/dynamic-api/src/interfaces/dynamic-api-route-config.interface.spec.ts index 63bac1e7..d9164199 100644 --- a/libs/dynamic-api/src/interfaces/dynamic-api-route-config.interface.spec.ts +++ b/libs/dynamic-api/src/interfaces/dynamic-api-route-config.interface.spec.ts @@ -24,6 +24,13 @@ import { UpdateManyRouteConfig, UpdateOneRouteConfig, CustomOperationRouteConfig, + defineCreateCallback, + defineCreateManyCallback, + defineUpdateCallback, + defineUpdateManyCallback, + defineReplaceCallback, + defineDuplicateCallback, + defineDuplicateManyCallback, } from './dynamic-api-route-config.interface'; import { BeforeSaveCallback, @@ -453,6 +460,188 @@ describe('DynamicAPIRouteConfig (deprecated alias)', () => { }); }); +// --------------------------------------------------------------------------- +// BodyDTO generic — route configs with BodyDTO propagation +// --------------------------------------------------------------------------- + +describe('BodyDTO generic on write route configs', () => { + class Item extends BaseEntity { name: string; price: number; } + class CreateItemDto { name: string; discountCode?: string; } + class UpdateItemDto { price?: number; reason?: string; } + class ReplaceItemDto { name: string; price: number; featured?: boolean; } + class DuplicateOverrideDto { tag?: string; } + + const fakeCallbackMethods = {} as CallbackMethods; + + it('CreateOneRouteConfig — ctx.toCreate typed as Partial', async () => { + let capturedCode = ''; + + const cfg: CreateOneRouteConfig = { + type: 'CreateOne', + beforeSaveCallback: async (_e, ctx, _m) => { + capturedCode = ctx.toCreate.discountCode ?? 'none'; + return { name: ctx.toCreate.name }; + }, + }; + + await cfg.beforeSaveCallback!(undefined, { toCreate: { name: 'Widget', discountCode: 'PROMO' } }, fakeCallbackMethods); + expect(capturedCode).toBe('PROMO'); + }); + + it('UpdateOneRouteConfig — ctx.update typed as Partial', async () => { + let capturedReason = ''; + + const cfg: UpdateOneRouteConfig = { + type: 'UpdateOne', + beforeSaveCallback: async (_e, ctx, _m) => { + capturedReason = ctx.update.reason ?? ''; + return { price: ctx.update.price }; + }, + }; + + await cfg.beforeSaveCallback!(undefined, { id: '1', update: { price: 50, reason: 'sale' } }, fakeCallbackMethods); + expect(capturedReason).toBe('sale'); + }); + + it('ReplaceOneRouteConfig — ctx.replacement typed as Partial', async () => { + let capturedFeatured: boolean | undefined; + + const cfg: ReplaceOneRouteConfig = { + type: 'ReplaceOne', + beforeSaveCallback: async (_e, ctx, _m) => { + capturedFeatured = ctx.replacement.featured; + return { name: ctx.replacement.name, price: ctx.replacement.price }; + }, + }; + + await cfg.beforeSaveCallback!(undefined, { id: '1', replacement: { name: 'A', price: 10, featured: true } }, fakeCallbackMethods); + expect(capturedFeatured).toBe(true); + }); + + it('DuplicateOneRouteConfig — ctx.override typed as Partial', async () => { + let capturedTag = ''; + + const cfg: DuplicateOneRouteConfig = { + type: 'DuplicateOne', + beforeSaveCallback: async (_e, ctx, _m) => { + capturedTag = ctx.override?.tag ?? ''; + return {}; + }, + }; + + await cfg.beforeSaveCallback!(undefined, { id: '1', override: { tag: 'copy' } }, fakeCallbackMethods); + expect(capturedTag).toBe('copy'); + }); +}); + +// --------------------------------------------------------------------------- +// defineXxxCallback helpers +// --------------------------------------------------------------------------- + +describe('defineXxxCallback helpers — eliminate `as never` casts', () => { + class Message extends BaseEntity { text: string; } + class ReactBody { emojiId: string; } + class CreateMsgDto { text: string; pack?: string; } + class ReplaceDto { text: string; featured?: boolean; } + class DupOverride { tag?: string; } + + const fakeCallbackMethods = {} as CallbackMethods; + + it('defineCreateCallback — ctx.toCreate typed as Partial', async () => { + let capturedPack = ''; + + const cb = defineCreateCallback( + async (_e, ctx, _m) => { + capturedPack = ctx.toCreate.pack ?? 'default'; + return { text: ctx.toCreate.text }; + }, + ); + + await cb(undefined, { toCreate: { text: 'hi', pack: 'emoji-v2' } }, fakeCallbackMethods); + expect(capturedPack).toBe('emoji-v2'); + }); + + it('defineCreateManyCallback — ctx.toCreate is array Partial', async () => { + const cb = defineCreateManyCallback( + async (_e, ctx, _m) => ctx.toCreate.map((d) => ({ text: d.text })), + ); + + const result = await cb(undefined, { toCreate: [{ text: 'a', pack: 'p1' }, { text: 'b' }] }, fakeCallbackMethods); + expect(result).toHaveLength(2); + }); + + it('defineUpdateCallback — ctx.update typed as Partial', async () => { + let capturedEmoji = ''; + + const cb = defineUpdateCallback( + async (_e, ctx, _m) => { + capturedEmoji = ctx.update.emojiId ?? ''; + return {}; + }, + ); + + await cb(undefined, { id: '1', update: { emojiId: '👍' } }, fakeCallbackMethods); + expect(capturedEmoji).toBe('👍'); + }); + + it('defineUpdateManyCallback — ctx.update typed as Partial', async () => { + const cb = defineUpdateManyCallback( + async (_e, ctx, _m) => [{ text: ctx.update.emojiId }], + ); + + const result = await cb(undefined, { ids: ['1', '2'], update: { emojiId: '❤️' } }, fakeCallbackMethods); + expect(result[0].text).toBe('❤️'); + }); + + it('defineReplaceCallback — ctx.replacement typed as Partial', async () => { + let capturedFeatured: boolean | undefined; + + const cb = defineReplaceCallback( + async (_e, ctx, _m) => { + capturedFeatured = ctx.replacement.featured; + return {}; + }, + ); + + await cb(undefined, { id: '1', replacement: { text: 'x', featured: true } }, fakeCallbackMethods); + expect(capturedFeatured).toBe(true); + }); + + it('defineDuplicateCallback — ctx.override typed as Partial', async () => { + let capturedTag = ''; + + const cb = defineDuplicateCallback( + async (_e, ctx, _m) => { + capturedTag = ctx.override?.tag ?? ''; + return {}; + }, + ); + + await cb(undefined, { id: '1', override: { tag: 'promo' } }, fakeCallbackMethods); + expect(capturedTag).toBe('promo'); + }); + + it('defineDuplicateManyCallback — ctx.override typed as Partial', async () => { + let capturedTag = ''; + + const cb = defineDuplicateManyCallback( + async (_e, ctx, _m) => { + capturedTag = ctx.override?.tag ?? ''; + return []; + }, + ); + + await cb(undefined, { ids: ['1'], override: { tag: 'clone' } }, fakeCallbackMethods); + expect(capturedTag).toBe('clone'); + }); + + it('helper returns the SAME function reference (identity)', () => { + const fn = async (_e: Message | undefined, _ctx: import('./dynamic-api-service-before-save-callback.interface').BeforeSaveUpdateContext, _m: CallbackMethods): Promise> => ({}); + const wrapped = defineUpdateCallback(fn); + expect(wrapped).toBe(fn); + }); +}); + diff --git a/libs/dynamic-api/src/interfaces/dynamic-api-route-config.interface.ts b/libs/dynamic-api/src/interfaces/dynamic-api-route-config.interface.ts index 0ea238df..99801718 100644 --- a/libs/dynamic-api/src/interfaces/dynamic-api-route-config.interface.ts +++ b/libs/dynamic-api/src/interfaces/dynamic-api-route-config.interface.ts @@ -61,46 +61,95 @@ interface BaseRouteConfig { fromUser?: FromUserMap; } -/** Route config for `CreateOne` — `beforeSaveCallback` receives {@link BeforeSaveCreateContext}. */ -interface CreateOneRouteConfig extends BaseRouteConfig { +/** Route config for `CreateOne` — `beforeSaveCallback` receives {@link BeforeSaveCreateContext}. + * + * @typeParam Entity The Mongoose entity class. + * @typeParam BodyDTO Body DTO class used with `dTOs.body`. Defaults to `Entity`. + * When provided, `ctx.toCreate` in `beforeSaveCallback` is typed as `Partial`. + * + * @example + * ```typescript + * const cfg: CreateOneRouteConfig = { + * type: 'CreateOne', + * dTOs: { body: CreateMessageDto }, + * beforeSaveCallback: async (_e, ctx, _m) => ({ text: ctx.toCreate.text }), + * // ^^^ ctx.toCreate is Partial — no cast + * }; + * ``` + */ +interface CreateOneRouteConfig extends BaseRouteConfig { type: 'CreateOne'; - beforeSaveCallback?: BeforeSaveCallback>; + beforeSaveCallback?: BeforeSaveCallback>; } -/** Route config for `CreateMany` — `beforeSaveCallback` receives {@link BeforeSaveCreateManyContext}. */ -interface CreateManyRouteConfig extends BaseRouteConfig { +/** Route config for `CreateMany` — `beforeSaveCallback` receives {@link BeforeSaveCreateManyContext}. + * + * @typeParam Entity The Mongoose entity class. + * @typeParam BodyDTO Body DTO class used with `dTOs.body`. Defaults to `Entity`. + */ +interface CreateManyRouteConfig extends BaseRouteConfig { type: 'CreateMany'; - beforeSaveCallback?: BeforeSaveListCallback>; + beforeSaveCallback?: BeforeSaveListCallback>; } -/** Route config for `UpdateOne` — `beforeSaveCallback` receives {@link BeforeSaveUpdateContext}. */ -interface UpdateOneRouteConfig extends BaseRouteConfig { +/** Route config for `UpdateOne` — `beforeSaveCallback` receives {@link BeforeSaveUpdateContext}. + * + * @typeParam Entity The Mongoose entity class. + * @typeParam BodyDTO Body DTO class used with `dTOs.body`. Defaults to `Entity`. + * When provided, `ctx.update` in `beforeSaveCallback` is typed as `Partial`. + * + * @example + * ```typescript + * const cfg: UpdateOneRouteConfig = { + * type: 'UpdateOne', + * dTOs: { body: ReactMessageBody }, + * beforeSaveCallback: messageReactCallback, // no cast needed + * }; + * ``` + */ +interface UpdateOneRouteConfig extends BaseRouteConfig { type: 'UpdateOne'; - beforeSaveCallback?: BeforeSaveCallback>; + beforeSaveCallback?: BeforeSaveCallback>; } -/** Route config for `UpdateMany` — `beforeSaveCallback` receives {@link BeforeSaveUpdateManyContext}. */ -interface UpdateManyRouteConfig extends BaseRouteConfig { +/** Route config for `UpdateMany` — `beforeSaveCallback` receives {@link BeforeSaveUpdateManyContext}. + * + * @typeParam Entity The Mongoose entity class. + * @typeParam BodyDTO Body DTO class used with `dTOs.body`. Defaults to `Entity`. + */ +interface UpdateManyRouteConfig extends BaseRouteConfig { type: 'UpdateMany'; - beforeSaveCallback?: BeforeSaveListCallback>; + beforeSaveCallback?: BeforeSaveListCallback>; } -/** Route config for `ReplaceOne` — `beforeSaveCallback` receives {@link BeforeSaveReplaceContext}. */ -interface ReplaceOneRouteConfig extends BaseRouteConfig { +/** Route config for `ReplaceOne` — `beforeSaveCallback` receives {@link BeforeSaveReplaceContext}. + * + * @typeParam Entity The Mongoose entity class. + * @typeParam BodyDTO Body DTO class used with `dTOs.body`. Defaults to `Entity`. + */ +interface ReplaceOneRouteConfig extends BaseRouteConfig { type: 'ReplaceOne'; - beforeSaveCallback?: BeforeSaveCallback>; + beforeSaveCallback?: BeforeSaveCallback>; } -/** Route config for `DuplicateOne` — `beforeSaveCallback` receives {@link BeforeSaveDuplicateContext}. */ -interface DuplicateOneRouteConfig extends BaseRouteConfig { +/** Route config for `DuplicateOne` — `beforeSaveCallback` receives {@link BeforeSaveDuplicateContext}. + * + * @typeParam Entity The Mongoose entity class. + * @typeParam BodyDTO Body DTO class for override fields. Defaults to `Entity`. + */ +interface DuplicateOneRouteConfig extends BaseRouteConfig { type: 'DuplicateOne'; - beforeSaveCallback?: BeforeSaveCallback>; + beforeSaveCallback?: BeforeSaveCallback>; } -/** Route config for `DuplicateMany` — `beforeSaveCallback` receives {@link BeforeSaveDuplicateManyContext}. */ -interface DuplicateManyRouteConfig extends BaseRouteConfig { +/** Route config for `DuplicateMany` — `beforeSaveCallback` receives {@link BeforeSaveDuplicateManyContext}. + * + * @typeParam Entity The Mongoose entity class. + * @typeParam BodyDTO Body DTO class for override fields. Defaults to `Entity`. + */ +interface DuplicateManyRouteConfig extends BaseRouteConfig { type: 'DuplicateMany'; - beforeSaveCallback?: BeforeSaveListCallback>; + beforeSaveCallback?: BeforeSaveListCallback>; } /** Route config for `DeleteOne` — `beforeSaveCallback` receives {@link BeforeSaveDeleteContext}. */ @@ -195,6 +244,105 @@ type DynamicApiRouteConfig = */ type DynamicAPIRouteConfig = DynamicApiRouteConfig; +// ─── Callback helpers — eliminate `as never` casts when using dTOs.body ────── + +/** + * Narrows a `BeforeSaveCallback` to the exact typed context for a `CreateOne` route, + * propagating a custom `BodyDTO`. + * + * Use this helper when you define a callback outside the route config object and TypeScript + * cannot infer `BodyDTO` from `dTOs.body`. + * + * @example + * ```typescript + * import { defineCreateCallback, BeforeSaveCreateContext } from 'mongodb-dynamic-api'; + * + * const beforeCreate = defineCreateCallback( + * async (_e, ctx, _m) => ({ text: ctx.toCreate.text }), + * ); + * + * // In the route config — no cast needed: + * { type: 'CreateOne', dTOs: { body: CreateMessageDto }, beforeSaveCallback: beforeCreate } + * ``` + */ +function defineCreateCallback( + cb: BeforeSaveCallback>, +): BeforeSaveCallback> { + return cb; +} + +/** + * Narrows a `BeforeSaveListCallback` to the exact typed context for a `CreateMany` route. + * @see {@link defineCreateCallback} for usage pattern. + */ +function defineCreateManyCallback( + cb: BeforeSaveListCallback>, +): BeforeSaveListCallback> { + return cb; +} + +/** + * Narrows a `BeforeSaveCallback` to the exact typed context for an `UpdateOne` route, + * propagating a custom `BodyDTO`. + * + * @example + * ```typescript + * import { defineUpdateCallback } from 'mongodb-dynamic-api'; + * + * const reactCallback = defineUpdateCallback( + * async (_e, ctx, _m) => ({ reaction: ctx.update.emojiId }), + * ); + * + * // In the route config — no cast needed: + * { type: 'UpdateOne', dTOs: { body: ReactMessageBody }, beforeSaveCallback: reactCallback } + * ``` + */ +function defineUpdateCallback( + cb: BeforeSaveCallback>, +): BeforeSaveCallback> { + return cb; +} + +/** + * Narrows a `BeforeSaveListCallback` to the exact typed context for an `UpdateMany` route. + * @see {@link defineUpdateCallback} for usage pattern. + */ +function defineUpdateManyCallback( + cb: BeforeSaveListCallback>, +): BeforeSaveListCallback> { + return cb; +} + +/** + * Narrows a `BeforeSaveCallback` to the exact typed context for a `ReplaceOne` route. + * @see {@link defineUpdateCallback} for usage pattern. + */ +function defineReplaceCallback( + cb: BeforeSaveCallback>, +): BeforeSaveCallback> { + return cb; +} + +/** + * Narrows a `BeforeSaveCallback` to the exact typed context for a `DuplicateOne` route. + * @see {@link defineUpdateCallback} for usage pattern. + */ +function defineDuplicateCallback( + cb: BeforeSaveCallback>, +): BeforeSaveCallback> { + return cb; +} + +/** + * Narrows a `BeforeSaveListCallback` to the exact typed context for a `DuplicateMany` route. + * @see {@link defineUpdateCallback} for usage pattern. + */ +function defineDuplicateManyCallback( + cb: BeforeSaveListCallback>, +): BeforeSaveListCallback> { + return cb; +} + export { BaseRouteConfig, CreateOneRouteConfig, @@ -213,4 +361,11 @@ export { DynamicApiRouteConfig, DynamicAPIRouteConfig, FromUserMap, + defineCreateCallback, + defineCreateManyCallback, + defineUpdateCallback, + defineUpdateManyCallback, + defineReplaceCallback, + defineDuplicateCallback, + defineDuplicateManyCallback, }; diff --git a/libs/dynamic-api/src/interfaces/dynamic-api-service-before-save-callback.interface.spec.ts b/libs/dynamic-api/src/interfaces/dynamic-api-service-before-save-callback.interface.spec.ts index 8ad2f267..5a7f2e6b 100644 --- a/libs/dynamic-api/src/interfaces/dynamic-api-service-before-save-callback.interface.spec.ts +++ b/libs/dynamic-api/src/interfaces/dynamic-api-service-before-save-callback.interface.spec.ts @@ -1,5 +1,6 @@ import { BaseEntity } from '../models'; import { + BeforeRegisterContext, BeforeSaveCallback, BeforeSaveCreateContext, BeforeSaveCreateManyContext, @@ -438,4 +439,70 @@ describe('deprecated aliases — BodyDTO propagation', () => { }); }); +// --------------------------------------------------------------------------- +// BeforeRegisterContext +// --------------------------------------------------------------------------- + +describe('BeforeRegisterContext', () => { + class UserEntity extends BaseEntity { + email: string; + password: string; + role?: string; + } + + const fakeCallbackMethods = {} as import('./dynamic-api-service-callback.interface').CallbackMethods; + + it('has hashedPassword as string', () => { + const ctx: BeforeRegisterContext = { hashedPassword: '$2b$10$hash' }; + expect(ctx.hashedPassword).toBe('$2b$10$hash'); + }); + + it('BeforeSaveCallback typed with BeforeRegisterContext — receives hashedPassword', async () => { + let receivedHash = ''; + + const cb: BeforeSaveCallback = + async (user, ctx, _methods) => { + receivedHash = ctx.hashedPassword; + return { ...user, role: 'member' }; + }; + + const ctx: BeforeRegisterContext = { hashedPassword: 'hashed123' }; + const fakeUser = Object.assign(new UserEntity(), { id: 'u1', email: 'a@b.com', password: '' }); + + await cb(fakeUser, ctx, fakeCallbackMethods); + + expect(receivedHash).toBe('hashed123'); + }); + + it('callback that ignores ctx is assignable to BeforeSaveCallback', async () => { + // contravariance: a callback typed with Record context IS callable + // where BeforeRegisterContext is expected (duck typing / structural compatibility) + const cb: BeforeSaveCallback = + async (user, _ctx, _methods) => ({ role: 'user' }); + + const ctx: BeforeRegisterContext = { hashedPassword: 'h' }; + const fakeUser = Object.assign(new UserEntity(), { id: 'u1', email: 'a@b.com', password: '' }); + const result = await cb(fakeUser, ctx, fakeCallbackMethods); + + expect(result.role).toBe('user'); + }); + + it('BeforeRegisterContext + TExtra pattern — user typed as Entity & TExtra', async () => { + type WithDevice = UserEntity & { deviceToken?: string }; + + const cb: BeforeSaveCallback = + async (user, ctx, _methods) => { + const token = user?.deviceToken; + expect(token).toBe('tok123'); + return { role: 'member', password: ctx.hashedPassword }; + }; + + const fakeUser = Object.assign(new UserEntity(), { + id: 'u1', email: 'a@b.com', password: '', deviceToken: 'tok123', + }) as WithDevice; + + await cb(fakeUser, { hashedPassword: 'h' }, fakeCallbackMethods); + }); +}); + diff --git a/libs/dynamic-api/src/interfaces/dynamic-api-service-before-save-callback.interface.ts b/libs/dynamic-api/src/interfaces/dynamic-api-service-before-save-callback.interface.ts index 0c1c3cf0..2d371ed4 100644 --- a/libs/dynamic-api/src/interfaces/dynamic-api-service-before-save-callback.interface.ts +++ b/libs/dynamic-api/src/interfaces/dynamic-api-service-before-save-callback.interface.ts @@ -93,6 +93,27 @@ type BeforeSaveDuplicateManyContext override?: Partial; } +/** + * Context provided to `beforeSaveCallback` for the `register` auth route. + * + * Available immediately after the password is hashed and before the user document is persisted. + * Use it to set `role`, validate business rules, or strip extra request fields. + * + * @example + * import { BeforeRegisterContext, BeforeSaveCallback } from 'mongodb-dynamic-api'; + * + * const beforeRegister: BeforeSaveCallback = + * async (user, ctx, methods) => ({ + * ...user, + * role: 'member', + * password: ctx.hashedPassword, + * }); + */ +type BeforeRegisterContext = { + /** Bcrypt-hashed password, ready to be persisted. */ + hashedPassword: string; +}; + type BeforeSaveCallback, User = unknown> = ( entity: Entity | undefined, context: Context, @@ -210,6 +231,7 @@ export type { BeforeSaveDeleteManyContext, BeforeSaveDuplicateContext, BeforeSaveDuplicateManyContext, + BeforeRegisterContext, DynamicApiServiceBeforeSaveCallback, DynamicApiServiceBeforeSaveListCallback, DynamicApiServiceBeforeSaveDeleteCallback, diff --git a/libs/dynamic-api/src/interfaces/dynamic-api-web-socket.interface.ts b/libs/dynamic-api/src/interfaces/dynamic-api-web-socket.interface.ts index b4e36b7e..6c186732 100644 --- a/libs/dynamic-api/src/interfaces/dynamic-api-web-socket.interface.ts +++ b/libs/dynamic-api/src/interfaces/dynamic-api-web-socket.interface.ts @@ -13,6 +13,59 @@ type GatewayOptions = GatewayMetadata; type DynamicApiWebSocketOptions = GatewayOptions | boolean; +/** + * Configuration for a single custom WebSocket event handler registered via `enableDynamicAPIWebSockets`. + * + * Register any number of `socket.on(name, handler)` listeners in a declarative, type-safe way + * without mixing business logic into `onConnection`. + * + * @typeParam User - Shape of the authenticated user attached to the socket. Defaults to `unknown`. + * + * @example + * ```typescript + * import type { CustomSocketEventConfig } from 'mongodb-dynamic-api'; + * + * interface AppUser { id: string; isAdmin: boolean; familyId: string } + * + * const voiceCallEvent: CustomSocketEventConfig = { + * name: 'voice-call-state-change', + * handler: (socket, payload, user) => { + * if (payload?.callId) socket.to(`family-${user?.familyId}`).emit('voice-call-state-change', payload); + * }, + * }; + * + * const adminEvent: CustomSocketEventConfig = { + * name: 'admin-switch-family', + * predicate: (user) => user?.isAdmin === true, + * handler: async (socket, payload, user) => { ... }, + * }; + * ``` + */ +interface CustomSocketEventConfig { + /** Socket.IO event name to listen for. */ + name: string; + + /** + * Handler invoked each time the event is received on a connected socket. + * + * @param socket The socket that emitted the event. + * @param payload The raw payload sent by the client. Type it as needed in the handler body. + * @param user The authenticated user attached to the socket. `undefined` for unauthenticated sockets. + */ + handler: (socket: ExtendedSocket, payload: unknown, user?: User) => void | Promise; + + /** + * Optional guard — if provided, the handler is only invoked when `predicate(user)` returns `true`. + * Use this instead of a hard-coded `isAdmin` check for full flexibility. + * + * @example + * ```typescript + * predicate: (user) => (user as AppUser)?.isAdmin === true, + * ``` + */ + predicate?: (user?: User) => boolean; +} + /** * Options object accepted by the new `enableDynamicAPIWebSockets(app, options)` overload. */ @@ -20,12 +73,39 @@ interface DynamicApiWebSocketSetupOptions { /** Maximum number of event listeners (defaults to 10). */ maxListeners?: number; /** Hook called on every new socket connection after JWT verification. */ - onConnection?: (socket: ExtendedSocket, user?: any) => void | Promise; + onConnection?: (socket: ExtendedSocket, user?: unknown) => void | Promise; /** When `true`, gateways and the socket adapter will emit debug logs. */ debug?: boolean; + /** + * Declarative custom Socket.IO event handlers registered on every new connection. + * + * Each entry is equivalent to calling `socket.on(name, handler)` inside `onConnection`, + * but keeps business logic out of the connection hook and provides full type safety. + * + * @example + * ```typescript + * enableDynamicAPIWebSockets(app, { + * customEvents: [ + * { + * name: 'voice-call-state-change', + * handler: (socket, payload, user) => { + * socket.to(`family-${user?.familyId}`).emit('voice-call-state-change', payload); + * }, + * }, + * { + * name: 'admin-action', + * predicate: (user) => user?.isAdmin === true, + * handler: async (socket, payload, user) => { ... }, + * }, + * ], + * }); + * ``` + */ + customEvents?: CustomSocketEventConfig[]; } export type { + CustomSocketEventConfig, DynamicApiWebSocketOptions, DynamicApiWebSocketSetupOptions, ExtendedSocket, diff --git a/libs/dynamic-api/src/modules/auth/interfaces/auth-options.interface.ts b/libs/dynamic-api/src/modules/auth/interfaces/auth-options.interface.ts index 939f50c6..e36ec8fc 100644 --- a/libs/dynamic-api/src/modules/auth/interfaces/auth-options.interface.ts +++ b/libs/dynamic-api/src/modules/auth/interfaces/auth-options.interface.ts @@ -2,6 +2,7 @@ import { ModuleMetadata, NestInterceptor, Type, ValidationPipeOptions } from '@n import type { Request } from 'express'; import { AuthAbilityPredicate, + BeforeRegisterContext, BroadcastAbilityPredicate, BroadcastRooms, DynamicApiResetPasswordCallback, @@ -52,12 +53,33 @@ type DynamicApiGetAccountOptions = { broadcast?: DynamicApiAuthBroadcastConfig; }; -type DynamicApiRegisterOptions = { - beforeSaveCallback?: BeforeSaveCallback; +/** + * Options for the `register` auth route. + * + * @typeParam Entity - The user entity class. + * @typeParam TExtra - Optional shape of extra request fields (e.g. `deviceToken`) that are + * declared in `additionalFields` but not part of the entity type. + * Defaults to `Record` (no extras) so existing usage compiles unchanged. + * + * @example — typed extra fields, zero casts in callback + * ```typescript + * import { DynamicApiRegisterOptions, BeforeRegisterContext } from 'mongodb-dynamic-api'; + * + * const registerOptions: DynamicApiRegisterOptions = { + * additionalFields: [{ name: 'deviceToken', required: false }], + * beforeSaveCallback: async (user, ctx, methods) => { + * const token = user?.deviceToken; // ✅ fully typed, no cast + * return { ...user, role: 'member' }; + * }, + * }; + * ``` + */ +type DynamicApiRegisterOptions> = { + beforeSaveCallback?: BeforeSaveCallback; callback?: AfterSaveCallback; protected?: boolean; abilityPredicate?: AuthAbilityPredicate; - additionalFields?: (keyof Entity | { name: keyof Entity; required?: boolean })[]; + additionalFields?: (keyof (Entity & TExtra) | { name: keyof (Entity & TExtra); required?: boolean })[]; useInterceptors?: Type[]; broadcast?: DynamicApiAuthBroadcastConfig; }; @@ -129,12 +151,12 @@ type PasswordlessOptions = { callback?: AfterSaveCallback; }; -type DynamicApiAuthOptions = { +type DynamicApiAuthOptions> = { userEntity: Type; jwt?: DynamicApiJWTOptions; login?: DynamicApiLoginOptions; getAccount?: DynamicApiGetAccountOptions; - register?: DynamicApiRegisterOptions; + register?: DynamicApiRegisterOptions; updateAccount?: DynamicApiUpdateAccountOptions; resetPassword?: Partial>; refreshToken?: DynamicApiRefreshTokenOptions; diff --git a/libs/dynamic-api/src/routes/custom/create-custom-route-controller.spec.ts b/libs/dynamic-api/src/routes/custom/create-custom-route-controller.spec.ts index d5a27d24..4978ba03 100644 --- a/libs/dynamic-api/src/routes/custom/create-custom-route-controller.spec.ts +++ b/libs/dynamic-api/src/routes/custom/create-custom-route-controller.spec.ts @@ -256,6 +256,7 @@ describe('createCustomRouteController', () => { params, body, query, + req, }); }); @@ -298,7 +299,20 @@ describe('createCustomRouteController', () => { await instance.handle({}, {}, {}, undefined); expect(fakeHandler).toHaveBeenCalledWith( - expect.objectContaining({ user: undefined }), + expect.objectContaining({ user: undefined, req: undefined }), + ); + }); + + it('passes req object to handler context', async () => { + const Ctrl = makeController(); + const instance: ControllerInstanceShape = Object.create(Ctrl.prototype); + instance.model = {}; + + const req = { user: { id: 'u1' } }; + await instance.handle({}, {}, {}, req); + + expect(fakeHandler).toHaveBeenCalledWith( + expect.objectContaining({ req }), ); }); }); @@ -347,6 +361,38 @@ describe('createCustomRouteController', () => { // ── version inheritance ────────────────────────────────────────────────── + describe('useInterceptors (route-level)', () => { + it('applies route-level UseInterceptors decorator to the handle method', () => { + class LoggingInterceptor { + intercept = jest.fn(); + } + + const Ctrl = createCustomRouteController( + FakeEntity, + fakeControllerOptions, + { + path: 'upload', + method: 'POST', + handler: fakeHandler, + useInterceptors: [LoggingInterceptor as never], + }, + ) as unknown as CustomRouteControllerClass; + + // The controller must exist and compile without error + expect(Ctrl).toBeDefined(); + }); + + it('does not throw when no route-level useInterceptors provided', () => { + expect(() => { + createCustomRouteController( + FakeEntity, + fakeControllerOptions, + { path: 'resource', method: 'GET', handler: fakeHandler }, + ); + }).not.toThrow(); + }); + }); + describe('version', () => { it('inherits controllerVersion when customRoute.version is not set', () => { const Ctrl = createCustomRouteController( diff --git a/libs/dynamic-api/src/routes/custom/create-custom-route-controller.ts b/libs/dynamic-api/src/routes/custom/create-custom-route-controller.ts index ecba71a1..0ac124e9 100644 --- a/libs/dynamic-api/src/routes/custom/create-custom-route-controller.ts +++ b/libs/dynamic-api/src/routes/custom/create-custom-route-controller.ts @@ -92,6 +92,7 @@ function createCustomRouteController< predicateBehavior, validationPipeOptions: routeValidationPipeOptions, dTOs, + useInterceptors: routeInterceptors = [], } = customRouteConfig; const { path: controllerPath, apiTag } = controllerOptions; @@ -150,6 +151,7 @@ function createCustomRouteController< params: params as Params, body: body as Body, query: query as QueryDto, + req, }); const fromEntity = (presenterType as Mappable).fromEntity; @@ -200,6 +202,11 @@ function createCustomRouteController< UseGuards(...allGuards)(CustomRouteController.prototype, 'handle', descriptor); } + // ─── Route-level interceptors (e.g. FileInterceptor) ────────────────────── + if (routeInterceptors.length > 0) { + UseInterceptors(...routeInterceptors)(CustomRouteController.prototype, 'handle', descriptor); + } + if (isPublic) { Public()(CustomRouteController.prototype, 'handle', descriptor); } else if (isAuthEnabled) { From 6209cfa7e6c2c10373a0f425a527efbe8fcede21 Mon Sep 17 00:00:00 2001 From: Mickael Date: Tue, 26 May 2026 18:17:10 +0000 Subject: [PATCH 2/2] chore(release): 4.14.0 --- CHANGELOG.md | 6 ++++++ libs/dynamic-api/src/version.json | 2 +- package-lock.json | 4 ++-- package.json | 2 +- 4 files changed, 10 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index da322af6..53cf230c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ Changelog +## [4.14.0](https://github.com/MikeDev75015/mongodb-dynamic-api/compare/v4.13.0...v4.14.0) (2026-05-26) + +### typing + +* **typing:** 6 upstream improvements — BeforeRegisterContext, TExtra, BodyDTO generics, customRoutes req/interceptors, WS customEvents ([bab7aec](https://github.com/MikeDev75015/mongodb-dynamic-api/commit/bab7aecf5400954c6f21c1e99434174b892cde1d)), closes [#5](https://github.com/MikeDev75015/mongodb-dynamic-api/issues/5) [#2](https://github.com/MikeDev75015/mongodb-dynamic-api/issues/2) [#4](https://github.com/MikeDev75015/mongodb-dynamic-api/issues/4) [#3](https://github.com/MikeDev75015/mongodb-dynamic-api/issues/3) [#6](https://github.com/MikeDev75015/mongodb-dynamic-api/issues/6) + ## [4.13.0](https://github.com/MikeDev75015/mongodb-dynamic-api/compare/v4.12.0...v4.13.0) (2026-05-26) ### route-config diff --git a/libs/dynamic-api/src/version.json b/libs/dynamic-api/src/version.json index 6f08b207..1ff06865 100644 --- a/libs/dynamic-api/src/version.json +++ b/libs/dynamic-api/src/version.json @@ -1,3 +1,3 @@ { - "version": "4.13.0" + "version": "4.14.0" } diff --git a/package-lock.json b/package-lock.json index 1229cddf..3cca9e74 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "mongodb-dynamic-api", - "version": "4.13.0", + "version": "4.14.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "mongodb-dynamic-api", - "version": "4.13.0", + "version": "4.14.0", "license": "MIT", "dependencies": { "@nestjs/cache-manager": "^3.0.1", diff --git a/package.json b/package.json index dc2d2c0c..30162810 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "mongodb-dynamic-api", - "version": "4.13.0", + "version": "4.14.0", "description": "Auto generated CRUD API for MongoDB using NestJS", "readmeFilename": "README.md", "main": "index.js",