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/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) { 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",